Compare commits
8 Commits
v0.1.0-rc9
...
v0.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 15495d41ea | |||
| 9d174d8db1 | |||
| 19f851afb0 | |||
| 9bdbcd4d88 | |||
| 113616b039 | |||
| 5da30ad058 | |||
| 7306f6be6d | |||
| 15e4156802 |
+48
-10
@@ -17,25 +17,41 @@ concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We deliberately avoid actions/cache@v4 and actions/upload-artifact@v4 in this
|
||||
# workflow. They are Node.js-based and the runner image (golang:*) has no Node.
|
||||
# Installing Node would add ~10s per job for every run; for now we accept
|
||||
# that go module downloads happen on each invocation (~20-30s once warm).
|
||||
# Real release artifacts are produced by release.yml via curl + Forgejo API.
|
||||
# Node.js is installed at the start of every job so actions/cache@v4
|
||||
# (which uses Node) can run. Installing Node costs ~10s; the resulting
|
||||
# Go module + apt cache restore typically saves 30-60s on warm runs.
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go
|
||||
steps:
|
||||
- name: Disable apt auto-clean (preserve cache for actions/cache)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
|
||||
- name: Install Node (required by actions/cache)
|
||||
run: |
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends nodejs >/dev/null
|
||||
|
||||
- name: Checkout
|
||||
env:
|
||||
# Gitea Actions doesn't auto-export GITHUB_TOKEN to the shell;
|
||||
# we have to read it from secrets and surface it explicitly.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@git.okcu.io/${GITHUB_REPOSITORY}.git" /tmp/src
|
||||
git -C /tmp/src checkout "$GITHUB_SHA"
|
||||
cp -a /tmp/src/. .
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: go-${{ runner.os }}-
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Test with race
|
||||
@@ -50,15 +66,33 @@ jobs:
|
||||
runs-on: go
|
||||
needs: test
|
||||
steps:
|
||||
- name: Disable apt auto-clean (preserve cache for actions/cache)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
|
||||
- name: Install Node (required by actions/cache)
|
||||
run: |
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends nodejs >/dev/null
|
||||
|
||||
- name: Checkout
|
||||
env:
|
||||
# Gitea Actions doesn't auto-export GITHUB_TOKEN to the shell;
|
||||
# we have to read it from secrets and surface it explicitly.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@git.okcu.io/${GITHUB_REPOSITORY}.git" /tmp/src
|
||||
git -C /tmp/src checkout "$GITHUB_SHA"
|
||||
cp -a /tmp/src/. .
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: go-${{ runner.os }}-
|
||||
|
||||
- name: Cross-compile drover.exe (windows/amd64)
|
||||
env:
|
||||
GOOS: windows
|
||||
@@ -68,7 +102,11 @@ jobs:
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
BUILD_DATE="$(date -u +%Y-%m-%d)"
|
||||
mkdir -p bin
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
# -H=windowsgui = subsystem WINDOWS, double-click no longer
|
||||
# flashes a console window. main.go calls AttachConsole on
|
||||
# startup so CLI invocations from cmd/PowerShell still print
|
||||
# to the parent terminal.
|
||||
go build -trimpath -ldflags="-s -w -H=windowsgui \
|
||||
-X main.Version=dev-${SHORT_SHA} \
|
||||
-X main.Commit=${SHORT_SHA} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
|
||||
@@ -16,6 +16,20 @@ jobs:
|
||||
release:
|
||||
runs-on: go
|
||||
steps:
|
||||
# Debian-based Docker images ship /etc/apt/apt.conf.d/docker-clean
|
||||
# which deletes /var/cache/apt/archives after every apt-get install.
|
||||
# That defeats actions/cache for the apt cache; disable it before
|
||||
# any apt operations.
|
||||
- name: Disable apt auto-clean (preserve cache for actions/cache)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
|
||||
- name: Install Node (required by actions/cache)
|
||||
run: |
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends nodejs >/dev/null
|
||||
|
||||
- name: Checkout
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -24,6 +38,24 @@ jobs:
|
||||
git -C /tmp/src checkout "$GITHUB_SHA"
|
||||
cp -a /tmp/src/. .
|
||||
|
||||
# Cache Go modules — saves ~10-20s on warm runs.
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: go-${{ runner.os }}-
|
||||
|
||||
# NOTE: actions/cache for the apt archive (~300 MB) is disabled. The
|
||||
# save step works (~28s in v0.1.4) but restore times out on the
|
||||
# next run — Gitea's cache server can't push 300 MB back fast
|
||||
# enough. The Wine + Inno Setup install stays at ~1m20s. The
|
||||
# right fix is a pre-baked Docker image (golang:1.25 + wine +
|
||||
# innoextract + xauth + wine32:i386) pushed to git.okcu.io as
|
||||
# the job's container.image. Tracked as future work.
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -42,7 +74,11 @@ jobs:
|
||||
BUILD_DATE="$(date -u +%Y-%m-%d)"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
mkdir -p dist
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
# -H=windowsgui makes drover.exe a GUI subsystem binary so the
|
||||
# double-click experience doesn't flash a console window. main.go
|
||||
# calls AttachConsole on startup so CLI runs still print to the
|
||||
# parent terminal when launched from cmd/PowerShell.
|
||||
go build -trimpath -ldflags="-s -w -H=windowsgui \
|
||||
-X main.Version=${{ steps.version.outputs.version }} \
|
||||
-X main.Commit=${SHORT_SHA} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func autoUpdateOnStartup() {}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/updater"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// autoUpdateOnStartup silently checks for and applies updates whenever
|
||||
// drover.exe starts as a GUI app (no CLI subcommand). Chrome-style: no
|
||||
// prompt, no progress bar, no questions — if an update is available we
|
||||
// download, verify, apply, and re-launch. The user just sees the new
|
||||
// version's window appear instead of the old one.
|
||||
//
|
||||
// Network failures, server outages, slow downloads, and "no updates
|
||||
// available" are silent fall-throughs — startup must never block on them.
|
||||
//
|
||||
// Two split contexts:
|
||||
// - check (8s) — quick HEAD-equivalent against the releases API
|
||||
// - apply (60s) — actual download + sha256 + atomic replace
|
||||
func autoUpdateOnStartup() {
|
||||
src := updater.NewForgejoSource("git.okcu.io", "root", "drover-go", "windows-amd64.exe")
|
||||
|
||||
checkCtx, cancelCheck := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
rel, hasUpdate, err := updater.CheckForUpdate(checkCtx, src, Version)
|
||||
cancelCheck()
|
||||
if err != nil || !hasUpdate {
|
||||
return
|
||||
}
|
||||
|
||||
applyCtx, cancelApply := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancelApply()
|
||||
if err := updater.ApplyUpdate(applyCtx, rel, nil); err != nil {
|
||||
// Apply failed — surface this one (sha mismatch, write error,
|
||||
// disk full are not silent-fail cases). The user can keep using
|
||||
// the current version after dismissing the dialog.
|
||||
errorDialog(fmt.Sprintf("Update to %s failed: %v\n\nContinuing on current version.", rel.TagName, err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := relaunchSelf(); err != nil {
|
||||
errorDialog(fmt.Sprintf("Update applied but re-launch failed: %v\n\nPlease restart drover.exe manually.", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Successfully spawned the new version — exit cleanly so it takes over.
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func errorDialog(msg string) {
|
||||
user32 := windows.NewLazySystemDLL("user32.dll")
|
||||
messageBox := user32.NewProc("MessageBoxW")
|
||||
|
||||
bodyW, _ := windows.UTF16PtrFromString(msg)
|
||||
titleW, _ := windows.UTF16PtrFromString("Drover-Go — Error")
|
||||
|
||||
// MB_OK | MB_ICONERROR | MB_TOPMOST
|
||||
const flags = 0x00000000 | 0x00000010 | 0x00040000
|
||||
|
||||
messageBox.Call(0, uintptr(unsafe.Pointer(bodyW)), uintptr(unsafe.Pointer(titleW)), flags)
|
||||
}
|
||||
|
||||
// relaunchSelf starts a fresh copy of the (now-updated) executable in the
|
||||
// background and returns. The caller is expected to os.Exit(0) immediately
|
||||
// after — the OS handles the brief overlap fine, and the new process inherits
|
||||
// nothing from us beyond the working directory and arguments.
|
||||
func relaunchSelf() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate self: %w", err)
|
||||
}
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func attachToParentConsole() {}
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// attachToParentConsole reconnects stdin/stdout/stderr to the console of the
|
||||
// process that launched us, if any. It is a no-op when the binary was started
|
||||
// from Explorer (double-click) — there is no parent console to attach to.
|
||||
//
|
||||
// Why we need this: the binary is built with -H=windowsgui, which means
|
||||
// Windows treats it as a GUI application and does NOT allocate a console
|
||||
// when it starts. That gives a clean double-click experience (no flashing
|
||||
// console window). But it also disconnects fmt.Println output when the
|
||||
// user runs us from cmd.exe / PowerShell. Calling AttachConsole here
|
||||
// re-attaches us to the parent's console so CLI output works as expected.
|
||||
func attachToParentConsole() {
|
||||
const ATTACH_PARENT_PROCESS = ^uint32(0) // (DWORD)-1
|
||||
|
||||
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
|
||||
attachConsole := kernel32.NewProc("AttachConsole")
|
||||
|
||||
r, _, _ := attachConsole.Call(uintptr(ATTACH_PARENT_PROCESS))
|
||||
if r == 0 {
|
||||
// AttachConsole failed — most likely there is no parent console
|
||||
// (we were started from Explorer). That's fine: GUI mode is the
|
||||
// default UX and stdout/stderr just go nowhere.
|
||||
return
|
||||
}
|
||||
|
||||
// Re-bind os.Stdout / os.Stderr to the freshly-attached console.
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdout = os.NewFile(uintptr(h), "stdout")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
|
||||
os.Stderr = os.NewFile(uintptr(h), "stderr")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdin = os.NewFile(uintptr(h), "stdin")
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,10 @@ func showTestWindow() {
|
||||
bodyW, _ := windows.UTF16PtrFromString(body)
|
||||
titleW, _ := windows.UTF16PtrFromString(title)
|
||||
|
||||
// MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND
|
||||
const flags = 0x00000000 | 0x00000040 | 0x00010000
|
||||
// MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND | MB_TOPMOST
|
||||
// MB_TOPMOST is essential — without it the message box can pop up
|
||||
// behind other windows and the user thinks nothing happened.
|
||||
const flags = 0x00000000 | 0x00000040 | 0x00010000 | 0x00040000
|
||||
|
||||
messageBox.Call(
|
||||
0,
|
||||
|
||||
@@ -22,6 +22,12 @@ var (
|
||||
var configPath string
|
||||
|
||||
func main() {
|
||||
// On Windows the binary is linked with -H=windowsgui so a double-click
|
||||
// doesn't flash a console window. When the user runs us from cmd or
|
||||
// PowerShell we still want stdout/stderr to land in their terminal —
|
||||
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
|
||||
attachToParentConsole()
|
||||
|
||||
// Inject our build version so the updater package can stamp it on the
|
||||
// User-Agent header it sends to git.okcu.io.
|
||||
updater.SetVersion(Version)
|
||||
@@ -39,6 +45,15 @@ func newRootCmd() *cobra.Command {
|
||||
Version: fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, BuildDate),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: false,
|
||||
// No subcommand and no flags = end-user double-clicked the exe.
|
||||
// First do a quick update check (silent if no network or already
|
||||
// current); if an update is available we prompt, apply, and
|
||||
// re-launch ourselves. Then show the smoke-test window.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
autoUpdateOnStartup()
|
||||
showTestWindow()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Custom version template: "drover-go vX.Y.Z (commit abc1234, built 2026-05-01)".
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go · Fluent</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<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&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: 'Inter', -apple-system, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
*{ box-sizing: border-box; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-fluent-live.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection id="fluent" title="01 · Fluent / Win11 native"
|
||||
subtitle="Системные шрифты Segoe UI Variable, акриловые карточки, Win11 acrylic-blue. Полный интерактив. Light + dark.">
|
||||
<DCArtboard id="fluent-light" label="light · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="light" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="fluent-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
function tryMount() {
|
||||
if (window.FluentWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else { setTimeout(tryMount, 50); }
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,249 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go — Desktop GUI explorations</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<template id="__bundler_thumbnail" data-bg-color="#f0eee9">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="800" fill="#f0eee9"/>
|
||||
<g transform="translate(600 400)">
|
||||
<circle r="160" fill="none" stroke="#2a7d76" stroke-width="22"/>
|
||||
<path d="M -90 -50 L 0 40 L 90 -50" fill="none" stroke="#2a7d76" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 0 40 L 0 130" fill="none" stroke="#2a7d76" stroke-width="22" stroke-linecap="round"/>
|
||||
</g>
|
||||
<text x="600" y="660" text-anchor="middle" font-family="ui-monospace, monospace" font-size="36" font-weight="700" fill="#2a251f" letter-spacing="6">DROVER-GO</text>
|
||||
</svg>
|
||||
</template>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-classic.jsx"></script>
|
||||
<script type="text/babel" src="drover-minimal.jsx"></script>
|
||||
<script type="text/babel" src="drover-glass.jsx"></script>
|
||||
<script type="text/babel" src="drover-brutalist.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
// For each variant we want to show several states side-by-side. To get
|
||||
// distinct states without manual interaction in the artboards, we use a
|
||||
// small "preset" wrapper that performs scripted setup against the variant's
|
||||
// useDrover hook by lifting it up: we pass an `initial` form and an
|
||||
// imperative control prop that the variant doesn't need — instead we just
|
||||
// re-mount with different presets to land at idle / checking / passed /
|
||||
// active / failed / failed+active states.
|
||||
//
|
||||
// We achieve this with a small Driver component that wraps the variant and
|
||||
// drives it through actions on mount.
|
||||
|
||||
function Driver({ Component, mode, preset }) {
|
||||
// preset: 'idle' | 'checking' | 'passed' | 'active' | 'failed' | 'active-warn' | 'auth-passed'
|
||||
const ref = React.useRef(null);
|
||||
const [boot, setBoot] = React.useState(false);
|
||||
React.useEffect(() => { setBoot(true); }, []);
|
||||
return (
|
||||
<PresetHost Component={Component} mode={mode} preset={preset}/>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-implements the variant render but injects a controller that drives
|
||||
// the inner useDrover state via portal. Simpler approach: forward an
|
||||
// `onReady` ref to the inner D state — but the variants don't expose D.
|
||||
// Easiest working approach: wrap each variant and intercept by re-mounting
|
||||
// through React + run actions via a *parallel* useDrover instance is not
|
||||
// possible. So: we expose a tiny "preset" API directly inside each variant
|
||||
// via the `initial` argument that mutates on first paint.
|
||||
|
||||
// The variants all accept an `initial` form. We extend the contract: the
|
||||
// variants now also accept `__preset` and `__scenario` to seed the state
|
||||
// machine on mount. To do that without rewriting all four files, we use a
|
||||
// shim: PresetVariant wraps a variant and uses an effect on the first
|
||||
// render of the inner DOM to dispatch synthetic clicks/programmatic state
|
||||
// via a "Programmatic" pattern: each variant exposes its useDrover-result
|
||||
// through a global hook map. Cleaner: just patch useDrover to broadcast.
|
||||
|
||||
// ── implementation: monkey-patch useDrover to record latest D per slot ──
|
||||
if (!window.__drvSlots) {
|
||||
const orig = window.useDrover;
|
||||
window.__drvSlots = new Map();
|
||||
window.useDrover = function(initial) {
|
||||
const D = orig(initial);
|
||||
const slot = (initial && initial.__slot) || null;
|
||||
React.useEffect(() => {
|
||||
if (slot) window.__drvSlots.set(slot, D);
|
||||
});
|
||||
return D;
|
||||
};
|
||||
}
|
||||
|
||||
function PresetHost({ Component, mode, preset }) {
|
||||
const slot = React.useId();
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const wait = () => new Promise(r => requestAnimationFrame(r));
|
||||
(async () => {
|
||||
await wait(); await wait();
|
||||
const D = window.__drvSlots.get(slot);
|
||||
if (!D || cancelled) return;
|
||||
if (preset === 'idle') return;
|
||||
if (preset === 'checking') {
|
||||
// Set scenario, kick check, but don't await — we want it mid-flight
|
||||
D.runCheck();
|
||||
return;
|
||||
}
|
||||
if (preset === 'passed') {
|
||||
D.setLogsOpen?.(false);
|
||||
D.runCheck();
|
||||
// wait for completion
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (preset === 'auth-passed') {
|
||||
D.update({ auth: true, login: 'drover', password: 'secret123' });
|
||||
await wait();
|
||||
const cur1 = window.__drvSlots.get(slot);
|
||||
cur1.runCheck();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (preset === 'failed') {
|
||||
// We need to switch scenario before runCheck.
|
||||
D.setScenario?.('udpFail');
|
||||
await wait();
|
||||
const cur1 = window.__drvSlots.get(slot);
|
||||
cur1.runCheck();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (preset === 'active') {
|
||||
D.runCheck();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
// let stats accumulate for a bit
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
if (preset === 'active-warn') {
|
||||
D.setScenario?.('udpFail');
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck?.();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; window.__drvSlots.delete(slot); };
|
||||
}, [preset, slot]);
|
||||
return <Component mode={mode} initial={{ __slot: slot }} />;
|
||||
}
|
||||
|
||||
// ─── Section header card with reasoning ────────────────────────────────
|
||||
function ReasoningCard() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 520, padding: '16px 18px',
|
||||
background: '#ffffff', border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
|
||||
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4, letterSpacing: 0.2 }}>Drover-Go · GUI explorations</div>
|
||||
<div style={{ color: '#5c5650' }}>
|
||||
Один экран · 480×640 · фикс. размер для Win11. Четыре стилистики, dark + light, по нескольку состояний:
|
||||
idle → checking → all-passed → active. Отдельные борды показывают сценарий с провалом UDP
|
||||
и активный режим с предупреждением. Все артборды — живые: кликабельны, имеют focus / hover / disabled.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
const variants = [
|
||||
{ id: 'classic', Component: window.ClassicWindow, title: 'Classic devtool',
|
||||
subtitle: 'Hairlines, dense mono metrics. Сдержанный teal-акцент. Под аудиторию sysadmin/devs.' },
|
||||
{ id: 'minimal', Component: window.MinimalWindow, title: 'Minimal · Fluent',
|
||||
subtitle: 'Под Windows 11. Карточки с микро-границей, мягкий slate-blue, system feel.' },
|
||||
{ id: 'glass', Component: window.GlassWindow, title: 'Glassmorphism',
|
||||
subtitle: 'Frosted blur поверх градиента, glow на active. Для тех, кому хочется vibe.' },
|
||||
{ id: 'brutal', Component: window.BrutWindow, title: 'Brutalist',
|
||||
subtitle: 'Жёсткие границы, без скруглений, моно. Acid-lime акцент, hard-shadow на кнопках.' },
|
||||
];
|
||||
const presets = [
|
||||
{ id: 'idle', label: 'idle (dark)', mode: 'dark', preset: 'idle' },
|
||||
{ id: 'idle-light', label: 'idle (light)', mode: 'light', preset: 'idle' },
|
||||
{ id: 'checking', label: 'checking… (dark)', mode: 'dark', preset: 'checking' },
|
||||
{ id: 'passed', label: 'all passed (dark)', mode: 'dark', preset: 'passed' },
|
||||
{ id: 'auth-passed', label: 'with auth (light)', mode: 'light', preset: 'auth-passed' },
|
||||
{ id: 'failed', label: 'UDP failed (light)', mode: 'light', preset: 'failed' },
|
||||
{ id: 'active', label: 'active proxy (dark)', mode: 'dark', preset: 'active' },
|
||||
{ id: 'active-warn', label: 'active · UDP warn (light)', mode: 'light', preset: 'active-warn' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DesignCanvas>
|
||||
<DCSection id="intro" title="About" subtitle="Brief & approach">
|
||||
<DCArtboard id="intro-card" label="reasoning" width={520} height={140}>
|
||||
<ReasoningCard/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
{variants.map(v => (
|
||||
<DCSection key={v.id} id={v.id} title={v.title} subtitle={v.subtitle}>
|
||||
{presets.map(p => (
|
||||
<DCArtboard key={p.id} id={`${v.id}-${p.id}`} label={p.label} width={W} height={H}>
|
||||
<PresetHost Component={v.Component} mode={p.mode} preset={p.preset}/>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
))}
|
||||
</DesignCanvas>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Wait until all script files loaded
|
||||
function tryMount() {
|
||||
if (window.ClassicWindow && window.MinimalWindow && window.GlassWindow && window.BrutWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else {
|
||||
setTimeout(tryMount, 50);
|
||||
}
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go — Shortlist</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<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&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: 'Inter', -apple-system, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
*{ box-sizing: border-box; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-classic.jsx"></script>
|
||||
<script type="text/babel" src="drover-fluent-live.jsx"></script>
|
||||
<script type="text/babel" src="drover-wizard-live.jsx"></script>
|
||||
<script type="text/babel" src="drover-hero-live.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection id="intro" title="Drover-Go · Shortlist" subtitle="Four directions you picked. Classic devtool is fully interactive (try it). The other three are sketches.">
|
||||
<DCArtboard id="intro-card" label="about" width={520} height={120}>
|
||||
<div data-dc-static style={{
|
||||
width: 520, padding: '16px 18px',
|
||||
background: '#ffffff', border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
|
||||
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>Shortlist · 4 directions · все живые</div>
|
||||
<div style={{ color: '#5c5650' }}>
|
||||
Все 4 варианта — полноценный интерактив. Кликай, заполняй форму, жми Check → Start.
|
||||
Разные UI-парадигмы (классика / Win11 / стэппер / VPN-кнопка), но одна и та же стейт-машина.
|
||||
</div>
|
||||
</div>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="classic" title="Classic devtool" subtitle="Hairlines, dense mono metrics, sober teal. Полноценный интерактив — кликай, тестируй. Dark + light.">
|
||||
<DCArtboard id="classic-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.ClassicWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="classic-light" label="light · interactive" width={W} height={H}>
|
||||
<window.ClassicWindow mode="light" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="fluent" title="01 · Fluent / Win11 native" subtitle="Системные шрифты, акриловые карточки, Win11 acrylic-blue. Полный интерактив. Light + dark.">
|
||||
<DCArtboard id="fluent-light" label="light · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="light" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="fluent-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="wizard" title="03 · Wizard / stepper" subtitle="3 шага: Configure → Verify → Connect. Шаги переключаются автоматически по состоянию (idle/checking/active). Light + dark.">
|
||||
<DCArtboard id="wizard-light" label="light · interactive" width={W} height={H}>
|
||||
<window.WizardWindow mode="light" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="wizard-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.WizardWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="hero" title="07 · Big toggle (VPN-style)" subtitle="Большая круглая кнопка — нажал, проверка пошла, при успехе авто-старт. Поля прокси — в раскрывающемся блоке снизу.">
|
||||
<DCArtboard id="hero-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.HeroWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="hero-light" label="light · interactive" width={W} height={H}>
|
||||
<window.HeroWindow mode="light" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
function tryMount() {
|
||||
if (window.ClassicWindow && window.FluentWindow && window.WizardWindow && window.HeroWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else {
|
||||
setTimeout(tryMount, 50);
|
||||
}
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Drover-Go · Style sketches</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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&family=JetBrains+Mono:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=DM+Mono:wght@400;500&family=Instrument+Serif&family=Fraunces:opsz,wght@9..144,400;9..144,600&family=VT323&family=Major+Mono+Display&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0eee9;font-family:'Inter',system-ui,sans-serif;color:#1a1a1a;-webkit-font-smoothing:antialiased}
|
||||
*{box-sizing:border-box}
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-sketches.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<SketchesApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go — round 2 (dark only)</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-compact.jsx"></script>
|
||||
<script type="text/babel" src="drover-cli.jsx"></script>
|
||||
<script type="text/babel" src="drover-studio.jsx"></script>
|
||||
<script type="text/babel" src="drover-workshop.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
// Patch useDrover to record latest D per slot id (for preset driving).
|
||||
if (!window.__drvSlots) {
|
||||
const orig = window.useDrover;
|
||||
window.__drvSlots = new Map();
|
||||
window.useDrover = function(initial) {
|
||||
const D = orig(initial);
|
||||
const slot = (initial && initial.__slot) || null;
|
||||
React.useEffect(() => { if (slot) window.__drvSlots.set(slot, D); });
|
||||
return D;
|
||||
};
|
||||
}
|
||||
|
||||
function PresetHost({ Component, preset }) {
|
||||
const slot = React.useId();
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const wait = () => new Promise(r => requestAnimationFrame(r));
|
||||
(async () => {
|
||||
await wait(); await wait();
|
||||
const D = window.__drvSlots.get(slot);
|
||||
if (!D || cancelled) return;
|
||||
if (preset === 'idle') return;
|
||||
if (preset === 'checking') { D.runCheck(); return; }
|
||||
const waitChecked = async () => {
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
if (window.__drvSlots.get(slot)?.phase === 'checked') break;
|
||||
}
|
||||
};
|
||||
if (preset === 'passed') { D.runCheck(); await waitChecked(); return; }
|
||||
if (preset === 'auth-passed') {
|
||||
D.update({ auth: true, login: 'drover', password: 'secret123' });
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck();
|
||||
await waitChecked(); return;
|
||||
}
|
||||
if (preset === 'failed') {
|
||||
D.setScenario?.('udpFail'); await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck();
|
||||
await waitChecked(); return;
|
||||
}
|
||||
if (preset === 'active') {
|
||||
D.runCheck(); await waitChecked(); await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
if (preset === 'active-warn') {
|
||||
D.setScenario?.('udpFail'); await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck(); await waitChecked(); await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; window.__drvSlots.delete(slot); };
|
||||
}, [preset, slot]);
|
||||
return <Component initial={{ __slot: slot }} />;
|
||||
}
|
||||
|
||||
function ReasoningCard() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 540, padding: '16px 18px', background: '#ffffff',
|
||||
border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
|
||||
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>Drover-Go · round 2</div>
|
||||
<div style={{ color: '#5c5650' }}>
|
||||
Только dark. 4 новых направления, более сдержанных и функциональных:
|
||||
Compact Pro (плотный, мониторинговый), CLI (терминал/фосфор),
|
||||
Studio (Linear-ish, спокойный), Workshop (industrial slate + amber).
|
||||
Каждый — 6 состояний: idle → checking → all-passed → with-auth → UDP-failed → active → active-warn.
|
||||
Отметь, какие нравятся — расширим в light + tweaks.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
const variants = [
|
||||
{ id: 'compact', Component: window.CompactWindow,
|
||||
title: '1 · Compact Pro', subtitle: 'Плотный, мониторинговый. Wireshark-ish. Минимум воздуха.' },
|
||||
{ id: 'cli', Component: window.CliWindow,
|
||||
title: '2 · Cyber CLI', subtitle: 'Фосфорный терминал. CRT-сканлайны. Всё через `> command --flag`.' },
|
||||
{ id: 'studio', Component: window.StudioWindow,
|
||||
title: '3 · Studio', subtitle: 'Современный, calm, Linear-ish. Тонкие карты, синий accent.' },
|
||||
{ id: 'workshop', Component: window.WorkshopWindow,
|
||||
title: '4 · Workshop', subtitle: 'Industrial slate + amber. Шильдики, лево-акцентный border.' },
|
||||
];
|
||||
const presets = [
|
||||
{ id: 'idle', label: 'idle' },
|
||||
{ id: 'checking', label: 'checking…' },
|
||||
{ id: 'passed', label: 'all passed' },
|
||||
{ id: 'auth-passed', label: 'with auth · passed' },
|
||||
{ id: 'failed', label: 'UDP failed' },
|
||||
{ id: 'active', label: 'active proxy' },
|
||||
{ id: 'active-warn', label: 'active · UDP warn' },
|
||||
];
|
||||
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection id="intro" title="About" subtitle="Round 2 — dark only">
|
||||
<DCArtboard id="intro-card" label="reasoning" width={540} height={150}>
|
||||
<ReasoningCard/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
{variants.map(v => (
|
||||
<DCSection key={v.id} id={v.id} title={v.title} subtitle={v.subtitle}>
|
||||
{presets.map(p => (
|
||||
<DCArtboard key={p.id} id={`${v.id}-${p.id}`} label={p.label} width={W} height={H}>
|
||||
<PresetHost Component={v.Component} preset={p.id}/>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
))}
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
function tryMount() {
|
||||
if (window.CompactWindow && window.CliWindow && window.StudioWindow && window.WorkshopWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else {
|
||||
setTimeout(tryMount, 50);
|
||||
}
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,622 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||
// State persists to a .design-canvas.state.json sidecar via the host
|
||||
// bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
||||
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const srcIds = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (!aid) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if (e.ctrlKey) {
|
||||
// trackpad pinch (or explicit ctrl+wheel)
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
return (
|
||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px 56px' }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
<div className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// drover-brutalist.jsx — Variant 4: Brutalist.
|
||||
// Hard borders, no rounding. Heavy mono. Monochrome + one acid lime accent.
|
||||
// Block-style "DOS-meets-zine" feeling. All-caps labels.
|
||||
|
||||
const BrutTheme = {
|
||||
d: {
|
||||
bg: '#0c0c0c', panel: '#0c0c0c', alt: '#161616',
|
||||
border: '#f4f4f4', borderDim: '#3a3a3a',
|
||||
text: '#f4f4f4', dim: '#a8a8a8', dimmer: '#6a6a6a',
|
||||
accent: '#c6ff00', danger: '#ff3a52', warn: '#ffaa00', pass: '#7eff84', skip: '#888',
|
||||
inputBg: '#0c0c0c', primaryFg: '#0c0c0c',
|
||||
},
|
||||
l: {
|
||||
bg: '#f4f1ea', panel: '#f4f1ea', alt: '#e9e5d8',
|
||||
border: '#0c0c0c', borderDim: '#bfb9a8',
|
||||
text: '#0c0c0c', dim: '#3a3a3a', dimmer: '#7a7568',
|
||||
accent: '#7d8f00', danger: '#b81e34', warn: '#a86c00', pass: '#1a7e2c', skip: '#7a7568',
|
||||
inputBg: '#f4f1ea', primaryFg: '#f4f1ea',
|
||||
},
|
||||
};
|
||||
|
||||
function BrutWindow({ mode = 'dark', initial }) {
|
||||
const t = BrutTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const isActive = D.phase === 'active';
|
||||
const fontMono = '"JetBrains Mono","IBM Plex Mono","Space Mono",ui-monospace,monospace';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontMono, fontSize: 12, overflow: 'hidden',
|
||||
border: `2px solid ${t.border}`, boxSizing:'border-box',
|
||||
}}>
|
||||
<BrutTitle t={t}/>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto' }}>
|
||||
{/* form */}
|
||||
<div style={{ padding: 14, borderBottom: `2px solid ${t.border}` }}>
|
||||
<BrutLabel t={t}>// SOCKS5 PROXY</BrutLabel>
|
||||
<div style={{ display:'flex', gap: 8 }}>
|
||||
<BField t={t} label="HOST" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 / example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
|
||||
</BField>
|
||||
<BField t={t} label="PORT" style={{ width: 92 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
|
||||
</BField>
|
||||
</div>
|
||||
<div style={{ height: 10 }}/>
|
||||
<BrutCheckbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('br-login')?.focus(),30); }}>
|
||||
AUTHENTICATION
|
||||
</BrutCheckbox>
|
||||
<div style={{ display:'flex', gap: 8, marginTop: 8, opacity: D.form.auth ? 1 : 0.4,
|
||||
pointerEvents: D.form.auth ? 'auto':'none' }}>
|
||||
<BField t={t} label="LOGIN" style={{ flex: 1 }}>
|
||||
<input id="br-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
|
||||
</BField>
|
||||
<BField t={t} label="PASSWORD" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="******"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
|
||||
</BField>
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
<BrutPrimary t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? '>> CHECKING…' : '>> CHECK CONNECTION'}
|
||||
</BrutPrimary>
|
||||
</div>
|
||||
|
||||
{/* status */}
|
||||
<div style={{ padding: 14, borderBottom: `2px solid ${t.border}` }}>
|
||||
<BrutLabel t={t}>// STATUS</BrutLabel>
|
||||
<BrutStatus t={t} D={D} palette={palette}/>
|
||||
</div>
|
||||
|
||||
{/* actions */}
|
||||
<div style={{ padding: 14 }}>
|
||||
<div style={{ display:'flex', gap: 8 }}>
|
||||
<BrutStartBtn t={t} D={D}/>
|
||||
<BrutStopBtn t={t} D={D}/>
|
||||
</div>
|
||||
{isActive && <BrutLiveStats t={t} stats={D.stats}/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrutLogs t={t} D={D}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrutTitle({ t }) {
|
||||
const cell = { width: 36, height: 28, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
|
||||
borderLeft: `2px solid ${t.border}`, color: t.text };
|
||||
return (
|
||||
<div style={{
|
||||
height: 30, background: t.alt, display:'flex', alignItems:'center',
|
||||
borderBottom: `2px solid ${t.border}`, userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 8, padding:'0 12px', flex:1 }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, background: t.accent, color: '#0c0c0c',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', fontWeight: 700, fontSize: 11,
|
||||
}}>D</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1.5 }}>DROVER-GO</span>
|
||||
<span style={{ fontSize: 10, color: t.dim }}>[v0.4.2]</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell} title="Settings"><window.IconGear color={t.text}/></div>
|
||||
<div style={cell} title="Minimize"><window.IconMin color={t.text}/></div>
|
||||
<div style={{ ...cell, background: t.danger, color: '#fff' }} title="Close"><window.IconClose color="#fff"/></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BrutLabel({ t, children }) {
|
||||
return <div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: 1.5, marginBottom: 10, color: t.dim }}>{children}</div>;
|
||||
}
|
||||
function BField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10, color: t.dim, fontWeight: 700, letterSpacing: 1 }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function brutInput(t, fontMono, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `2px solid ${disabled ? t.borderDim : t.border}`, borderRadius: 0,
|
||||
padding: '7px 9px', fontSize: 12, fontFamily: fontMono, outline:'none',
|
||||
width:'100%', boxSizing:'border-box',
|
||||
};
|
||||
}
|
||||
function BrutCheckbox({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 8, cursor:'pointer', userSelect:'none' }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, border: `2px solid ${t.border}`,
|
||||
background: checked ? t.accent : 'transparent',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
}}>
|
||||
{checked && <span style={{ fontWeight: 900, color: '#0c0c0c', fontSize: 12, lineHeight: 1 }}>×</span>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function BrutPrimary({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} style={{
|
||||
width:'100%', padding: '10px 12px', border: `2px solid ${t.border}`, borderRadius: 0,
|
||||
background: disabled ? t.alt : t.accent, color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, cursor: disabled?'not-allowed':'pointer',
|
||||
fontFamily: 'inherit', textAlign:'left',
|
||||
boxShadow: disabled ? 'none' : `4px 4px 0 ${t.border}`,
|
||||
transition: 'transform .08s, box-shadow .08s',
|
||||
...style,
|
||||
}}
|
||||
onMouseDown={e => { if (!disabled) { e.currentTarget.style.transform='translate(2px,2px)'; e.currentTarget.style.boxShadow=`2px 2px 0 ${t.border}`; }}}
|
||||
onMouseUp={e => { if (!disabled) { e.currentTarget.style.transform=''; e.currentTarget.style.boxShadow=`4px 4px 0 ${t.border}`; }}}
|
||||
onMouseLeave={e => { if (!disabled) { e.currentTarget.style.transform=''; e.currentTarget.style.boxShadow=`4px 4px 0 ${t.border}`; }}}
|
||||
>{children}</button>
|
||||
);
|
||||
}
|
||||
function BrutStatus({ t, D, palette }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, padding: '4px 0' }}>
|
||||
<span style={{ width: 10, height: 10, border: `2px solid ${t.dim}`, background: 'transparent' }}/>
|
||||
<span style={{ color: t.dim, fontSize: 11.5, letterSpacing: 0.5 }}>READY TO CHECK_</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 9, marginBottom: 10 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 700, fontSize: 11.5, letterSpacing: 0.7 }}>RUNNING DIAGNOSTICS…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding: '7px 10px', marginBottom: 10,
|
||||
background: D.lastSummary?.failed === 0 ? t.accent : t.warn,
|
||||
color: '#0c0c0c', fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5,
|
||||
border: `2px solid ${t.border}`,
|
||||
}}>
|
||||
{D.lastSummary?.failed === 0
|
||||
? '▮ ALL CHECKS PASSED. READY TO START.'
|
||||
: `▮ ${D.lastSummary?.failed}/${D.tests.length} FAILED · SOME FEATURES BROKEN`}
|
||||
</div>}
|
||||
<div style={{ border: `2px solid ${t.border}` }}>
|
||||
{D.tests.map((test, i) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const last = i === D.tests.length - 1;
|
||||
return (
|
||||
<div key={test.id} style={{ borderBottom: !last ? `1px solid ${t.borderDim}` : 'none' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 26, padding: '0 9px' }}>
|
||||
<window.StatusDot state={state} palette={palette} size={12}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 11.5 }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft:'auto', fontSize: 10.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:state==='passed'?t.pass:t.dim }}>
|
||||
{r?.metric || (state==='running'?'…':'')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.text,
|
||||
}}><window.IconChevron color={t.text} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
borderTop: `1px solid ${t.borderDim}`,
|
||||
background: t.alt, padding: 10, fontSize: 11,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 700, marginBottom: 4, letterSpacing: 0.5 }}>! {r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error}`)}
|
||||
style={{
|
||||
marginTop: 8, background: 'transparent', border: `1.5px solid ${t.border}`,
|
||||
color: t.text, padding: '3px 8px', fontSize: 10, cursor:'pointer',
|
||||
fontFamily: 'inherit', letterSpacing: 0.5, fontWeight: 700,
|
||||
}}>[ COPY ERROR ]</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BrutStartBtn({ t, D }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding: '10px 12px', border: `2px solid ${t.border}`,
|
||||
background: warning ? t.warn : t.accent, color: '#0c0c0c',
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, fontFamily: 'inherit',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
boxShadow: `4px 4px 0 ${t.border}`,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 9, height: 9, background: '#0c0c0c' }}/>
|
||||
●{warning ? ' ACTIVE / UDP-WARN' : ' ACTIVE'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <BrutPrimary t={t} disabled={!ok} onClick={D.startProxy} style={{ flex: 1 }}>{'>> START PROXYING'}</BrutPrimary>;
|
||||
}
|
||||
function BrutStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding: '10px 12px', border: `2px solid ${enabled?t.border:t.borderDim}`,
|
||||
background: t.bg, color: enabled ? t.text : t.dimmer,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, fontFamily: 'inherit',
|
||||
cursor: enabled ? 'pointer':'not-allowed', textAlign:'left',
|
||||
boxShadow: enabled ? `4px 4px 0 ${t.border}` : 'none',
|
||||
}}>{'■ STOP'}</button>
|
||||
);
|
||||
}
|
||||
function BrutLiveStats({ t, stats }) {
|
||||
const C = ({ icon, val, lbl }) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}<span style={{ fontSize: 11, fontWeight: 700 }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 9, color: t.dim, fontWeight: 700 }}>{lbl}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, padding: '7px 10px', border: `2px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
}}>
|
||||
<C icon={<window.IconArrowUp color={t.text}/>} val={window.fmtBytes(stats.up)}/>
|
||||
<C icon={<window.IconArrowDown color={t.text}/>} val={window.fmtBytes(stats.down)}/>
|
||||
<C val={stats.tcp} lbl="TCP"/>
|
||||
<C val={stats.udp} lbl="UDP"/>
|
||||
<C val={window.fmtUptime(stats.uptimeS)} lbl="UP"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BrutLogs({ t, D }) {
|
||||
return (
|
||||
<div style={{ borderTop: `2px solid ${t.border}`, background: t.alt, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '7px 14px', display:'flex', alignItems:'center', gap: 9,
|
||||
background:'transparent', border:'none', color: t.text, cursor:'pointer',
|
||||
fontSize: 11, fontFamily: 'inherit', fontWeight: 700, letterSpacing: 1.2,
|
||||
}}>
|
||||
<window.IconChevron color={t.text} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span>// LOGS</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dim }}>{D.logs.length} LINES</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding: '0 14px 7px' }}>
|
||||
{[['COPY ALL', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['CLEAR', D.clearLogs], ['OPEN FILE', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn || undefined} style={{
|
||||
background:'transparent', border:`1.5px solid ${t.border}`, color: t.text,
|
||||
padding: '3px 8px', fontSize: 9.5, fontWeight: 700, letterSpacing: 1, cursor:'pointer',
|
||||
fontFamily:'inherit',
|
||||
}}>[ {l} ]</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY: 'auto', padding: '7px 14px', fontSize: 10.5, lineHeight: 1.6, color: t.text }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dim }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{
|
||||
color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 700,
|
||||
}}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.BrutWindow = BrutWindow;
|
||||
@@ -0,0 +1,440 @@
|
||||
// drover-classic.jsx — Variant 1: Classic devtool.
|
||||
// Information-dense. Mono metrics. Plain rectangles, hairline borders.
|
||||
// Sober palette: neutral grays + one teal accent for primary action / success.
|
||||
|
||||
const ClassicTheme = {
|
||||
// dark
|
||||
d: {
|
||||
bg: '#1c1d20',
|
||||
chrome: '#15161a',
|
||||
panel: '#22242a',
|
||||
panelAlt: '#1a1c20',
|
||||
border: '#34373d',
|
||||
borderSoft:'#2a2c32',
|
||||
text: '#dde0e6',
|
||||
dim: '#8a8f99',
|
||||
dimmer: '#5b6068',
|
||||
accent: '#3ea99f', // teal
|
||||
accentDim: '#2a7d76',
|
||||
danger: '#d96565',
|
||||
warn: '#d9a155',
|
||||
pass: '#5cba8b',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#15161a',
|
||||
btnBg: '#2c2f36',
|
||||
btnBgH: '#373b43',
|
||||
primaryBg: '#3ea99f',
|
||||
primaryFg: '#0c1a18',
|
||||
},
|
||||
l: {
|
||||
bg: '#f3f4f6',
|
||||
chrome: '#e8eaef',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f8f9fb',
|
||||
border: '#d8dbe1',
|
||||
borderSoft:'#e6e8ec',
|
||||
text: '#1c1f24',
|
||||
dim: '#5c6168',
|
||||
dimmer: '#8a8f97',
|
||||
accent: '#2a7d76',
|
||||
accentDim: '#bdded9',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#2f8c5a',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#ffffff',
|
||||
btnBg: '#ffffff',
|
||||
btnBgH: '#f1f2f5',
|
||||
primaryBg: '#2a7d76',
|
||||
primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
function ClassicWindow({ mode = 'dark', initial }) {
|
||||
const t = ClassicTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace';
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const isActive = D.phase === 'active';
|
||||
const allChecked = D.phase === 'checked' || D.phase === 'active';
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13, lineHeight: 1.4, overflow: 'hidden',
|
||||
border: mode === 'dark' ? '1px solid #000' : '1px solid #c0c3c9',
|
||||
}}>
|
||||
{/* ─── title bar ─── */}
|
||||
<ClassicTitleBar t={t} />
|
||||
|
||||
{/* ─── content ─── */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '14px 16px 0' }}>
|
||||
|
||||
{/* Form */}
|
||||
<SectionLabel t={t}>SOCKS5 Proxy</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Field t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
<Field t={t} label="Port" style={{ width: 92 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Checkbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
|
||||
Authentication
|
||||
</Checkbox>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: 12, opacity: D.form.auth ? 1 : 0.45 }}>
|
||||
<Field t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="cls-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
<Field t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<PrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</PrimaryBtn>
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ height: 18 }} />
|
||||
<SectionLabel t={t}>Status</SectionLabel>
|
||||
<ClassicStatus t={t} D={D} palette={palette} fontMono={fontMono} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ height: 14 }} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<ClassicStartBtn t={t} D={D} fontMono={fontMono} />
|
||||
<ClassicStopBtn t={t} D={D} />
|
||||
</div>
|
||||
|
||||
{isActive && <ClassicLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
|
||||
|
||||
<div style={{ height: 14 }} />
|
||||
</div>
|
||||
|
||||
{/* Logs collapsible */}
|
||||
<ClassicLogs t={t} D={D} fontMono={fontMono} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── pieces ─────────────────────────────────────────────────────────────────
|
||||
function ClassicTitleBar({ t }) {
|
||||
const cellStyle = {
|
||||
width: 38, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
color: t.dim, cursor:'pointer',
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, background: t.chrome, borderBottom: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', alignItems:'center', WebkitAppRegion:'drag', userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 12px', flex:1 }}>
|
||||
<window.BrandMark size={14} color={t.accent}/>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.1 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer, fontFamily:'ui-monospace,monospace' }}>v0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex', WebkitAppRegion:'no-drag' }}>
|
||||
<div style={cellStyle} title="Settings"><window.IconGear color={t.dim}/></div>
|
||||
<div style={cellStyle} title="Minimize"><window.IconMin color={t.dim}/></div>
|
||||
<div style={{...cellStyle}} title="Close"
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#c0463f'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<window.IconClose color={t.dim}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children, t }) {
|
||||
return <div style={{
|
||||
fontSize: 10.5, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase',
|
||||
color: t.dim, marginBottom: 8,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
function Field({ children, label, t, style }) {
|
||||
return (
|
||||
<label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500 }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function inputStyle(t, fontMono, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.border}`, borderRadius: 3, padding: '7px 9px',
|
||||
fontFamily: fontMono, fontSize: 12, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
};
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, children, t }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 7, cursor:'pointer', userSelect:'none', fontSize: 12 }}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 2, border: `1px solid ${checked ? t.accent : t.border}`,
|
||||
background: checked ? t.accent : 'transparent', display:'flex', alignItems:'center', justifyContent:'center',
|
||||
transition: 'background .12s, border-color .12s',
|
||||
}}>
|
||||
{checked && <svg width="9" height="9" viewBox="0 0 9 9"><path d="M1.5 4.5l2 2 4-4" stroke={t.primaryFg} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryBtn({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
style={{
|
||||
width:'100%', padding:'9px 12px', border:'none', borderRadius: 3,
|
||||
background: disabled ? t.btnBg : t.primaryBg, color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 12.5, letterSpacing: 0.1, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
boxShadow: disabled ? 'none' : `inset 0 -1px 0 rgba(0,0,0,.18)`,
|
||||
transition: 'background .12s', ...style,
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── status panel ──────────────────────────────────────────────────────────
|
||||
function ClassicStatus({ t, D, palette, fontMono }) {
|
||||
const idle = D.phase === 'idle';
|
||||
if (idle) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4,
|
||||
padding: '14px 14px', display:'flex', alignItems:'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim, fontSize: 12.5 }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4, overflow:'hidden' }}>
|
||||
{/* header */}
|
||||
<div style={{
|
||||
padding: '8px 12px', display:'flex', alignItems:'center', gap: 8,
|
||||
borderBottom: `1px solid ${t.borderSoft}`, background: t.panelAlt, fontSize: 12,
|
||||
}}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<window.StatusDot state="running" palette={palette} size={12}/>
|
||||
<span>Running diagnostics…</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{Object.keys(D.results).length}/{D.tests.length}
|
||||
</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? <span style={{ color: t.pass, fontWeight: 600 }}>All checks passed. Ready to start.</span>
|
||||
: <span style={{ color: t.warn, fontWeight: 600 }}>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
|
||||
</div>
|
||||
{/* tests */}
|
||||
<div>
|
||||
{D.tests.map((test, i) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const isLast = i === D.tests.length - 1;
|
||||
return (
|
||||
<div key={test.id} style={{
|
||||
borderBottom: !isLast ? `1px solid ${t.borderSoft}` : 'none',
|
||||
padding: '6px 12px',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 22 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={12}/>
|
||||
<span style={{ fontSize: 12, color: state === 'pending' ? t.dim : t.text }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '...' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 21px', padding: '8px 10px', borderRadius: 3,
|
||||
background: mode_mix(t.danger, t.panel, 0.9), border: `1px solid ${mode_mix(t.danger, t.panel, 0.78)}`,
|
||||
fontSize: 11.5, color: t.text,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
|
||||
style={smallBtn(t, fontMono)}>
|
||||
<window.IconCopy color={t.dim}/> copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconBtnStyle(t) {
|
||||
return {
|
||||
width: 20, height: 20, padding: 0, border:'none', background:'transparent',
|
||||
cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
|
||||
borderRadius: 2,
|
||||
};
|
||||
}
|
||||
function smallBtn(t, fontMono) {
|
||||
return {
|
||||
display:'inline-flex', alignItems:'center', gap: 4, padding: '3px 7px',
|
||||
background: t.btnBg, border: `1px solid ${t.border}`, color: t.dim,
|
||||
borderRadius: 3, fontFamily: fontMono, fontSize: 10.5, cursor:'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
// crude color mix for dark/light. expects hex (#rrggbb), bg can be hex too. amount=share-of-bg.
|
||||
function mode_mix(fg, bg, amt) {
|
||||
const a = hexToRgb(fg), b = hexToRgb(bg);
|
||||
return `rgb(${Math.round(a.r*(1-amt)+b.r*amt)},${Math.round(a.g*(1-amt)+b.g*amt)},${Math.round(a.b*(1-amt)+b.b*amt)})`;
|
||||
}
|
||||
function hexToRgb(h) {
|
||||
const v = h.replace('#','');
|
||||
return { r: parseInt(v.slice(0,2),16), g: parseInt(v.slice(2,4),16), b: parseInt(v.slice(4,6),16) };
|
||||
}
|
||||
|
||||
// ─── start/stop ────────────────────────────────────────────────────────────
|
||||
function ClassicStartBtn({ t, D, fontMono }) {
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (summary?.failed || 0) > 0;
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
background: warning ? mode_mix(t.warn, t.panel, 0.85) : mode_mix(t.pass, t.panel, 0.85),
|
||||
border: `1px solid ${warning ? t.warn : t.pass}`,
|
||||
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5, fontFamily: fontMono,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{
|
||||
width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass,
|
||||
}}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1 }}>
|
||||
Start proxying
|
||||
</PrimaryBtn>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled}
|
||||
style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
||||
background: t.btnBg, color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.border}`, cursor: enabled ? 'pointer':'not-allowed',
|
||||
}}>
|
||||
Stop
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicLiveStats({ t, stats, fontMono }) {
|
||||
const cell = (icon, val) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4, color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{icon}<span>{val}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 8, padding: '6px 10px', borderRadius: 3,
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
}}>
|
||||
{cell(<window.IconArrowUp color={t.dim}/>, window.fmtBytes(stats.up))}
|
||||
{cell(<window.IconArrowDown color={t.dim}/>, window.fmtBytes(stats.down))}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>TCP</span>, stats.tcp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>UDP</span>, stats.udp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>↑t</span>, window.fmtUptime(stats.uptimeS))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── logs ──────────────────────────────────────────────────────────────────
|
||||
function ClassicLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '8px 14px', display:'flex', alignItems:'center', gap:8,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontSize: 11, fontFamily: fontMono, letterSpacing: 0.3,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ textTransform:'uppercase' }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}` }}>
|
||||
<div style={{ display:'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.borderSoft}` }}>
|
||||
<button style={smallBtn(t, fontMono)}
|
||||
onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all</button>
|
||||
<button style={smallBtn(t, fontMono)} onClick={D.clearLogs}>clear</button>
|
||||
<button style={smallBtn(t, fontMono)}>open log file</button>
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
|
||||
background: t.panelAlt,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ClassicWindow = ClassicWindow;
|
||||
@@ -0,0 +1,263 @@
|
||||
// drover-cli.jsx — Cyber CLI: terminal aesthetic, phosphor green, ASCII feel.
|
||||
// All-mono. No round corners. Subtle CRT scanline overlay.
|
||||
|
||||
const CLI = {
|
||||
bg: '#0a0d0a', chrome: '#070a07', panel: '#0d110d', panel2: '#111511',
|
||||
border: '#1f2a1f', borderSoft: '#162016',
|
||||
text: '#bfe7c0', // phosphor green-white
|
||||
dim: '#5a8a5e', dimmer: '#3a5a3d',
|
||||
accent: '#5cf08a', accentDim: '#2a8a44',
|
||||
danger: '#ff6b6b', warn: '#ffd166', pass: '#5cf08a', skip: '#6a8a6e',
|
||||
inputBg: '#0a0e0a',
|
||||
};
|
||||
|
||||
function CliWindow({ initial }) {
|
||||
const t = CLI;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontMono = '"JetBrains Mono","IBM Plex Mono","Cascadia Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontMono, fontSize: 12, overflow:'hidden', position:'relative',
|
||||
border:'1px solid #000', textShadow: `0 0 1px ${t.accent}30`,
|
||||
}}>
|
||||
{/* CRT scanlines */}
|
||||
<div style={{
|
||||
position:'absolute', inset:0, pointerEvents:'none', zIndex: 5, opacity: 0.18,
|
||||
background: 'repeating-linear-gradient(to bottom, transparent 0, transparent 2px, rgba(0,0,0,.55) 3px, transparent 4px)',
|
||||
}}/>
|
||||
{/* vignette */}
|
||||
<div style={{
|
||||
position:'absolute', inset:0, pointerEvents:'none', zIndex: 4,
|
||||
background: 'radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,.45) 100%)',
|
||||
}}/>
|
||||
|
||||
{/* title bar — like a terminal tab */}
|
||||
<div style={{
|
||||
height: 28, background: t.chrome, borderBottom:`1px solid ${t.border}`,
|
||||
display:'flex', alignItems:'center', position:'relative', zIndex: 6,
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 7, padding:'0 11px', flex:1 }}>
|
||||
<span style={{ color: t.accent }}>$</span>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600, letterSpacing: 0.6 }}>drover-go</span>
|
||||
<span style={{ fontSize: 10.5, color: t.dim }}>— ~ — 80×24</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
{['settings','min','close'].map(k => (
|
||||
<span key={k} style={{
|
||||
width: 32, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
cursor:'pointer', color: t.dim, fontSize: 11,
|
||||
}}>{k==='close'?'×':k==='min'?'−':'⚙'}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding: '10px 14px', position:'relative', zIndex: 6 }}>
|
||||
{/* prompt-style form */}
|
||||
<div style={cliHead(t)}>┌── socks5 proxy ─────────────────────────────────────────</div>
|
||||
<div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}`, paddingTop: 4, paddingBottom: 6 }}>
|
||||
<CliRow t={t}>
|
||||
<span style={{ color: t.dim }}>host:</span>
|
||||
<CliInput t={t} value={D.form.host} onChange={v => D.update({ host: v })}
|
||||
placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} style={{ flex: 1 }}/>
|
||||
<span style={{ color: t.dim }}>port:</span>
|
||||
<CliInput t={t} value={D.form.port} onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
placeholder="12334" onSubmit={D.runCheck} style={{ width: 70 }}/>
|
||||
</CliRow>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<label style={{ cursor:'pointer', userSelect:'none' }}>
|
||||
<span style={{ color: t.dim }}>auth:</span>{' '}
|
||||
<span onClick={() => {
|
||||
D.update({ auth: !D.form.auth });
|
||||
if (!D.form.auth) setTimeout(()=>document.getElementById('cli-login')?.focus(),30);
|
||||
}}
|
||||
style={{
|
||||
color: D.form.auth ? t.accent : t.dim, fontWeight: 600, cursor:'pointer',
|
||||
borderBottom: `1px dotted ${t.accent}`,
|
||||
}}>{D.form.auth ? '[ on ]' : '[ off ]'}</span>
|
||||
</label>
|
||||
</div>
|
||||
{D.form.auth && (
|
||||
<CliRow t={t} style={{ marginTop: 6 }}>
|
||||
<span style={{ color: t.dim }}>user:</span>
|
||||
<CliInput id="cli-login" t={t} value={D.form.login} onChange={v => D.update({ login: v })}
|
||||
placeholder="login" onSubmit={D.runCheck} style={{ flex: 1 }}/>
|
||||
<span style={{ color: t.dim }}>pass:</span>
|
||||
<CliInput t={t} value={D.form.password} onChange={v => D.update({ password: v })} type="password"
|
||||
placeholder="••••••" onSubmit={D.runCheck} style={{ flex: 1 }}/>
|
||||
</CliRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
|
||||
marginTop: 8, width:'100%', padding:'7px 12px', background:'transparent',
|
||||
border:`1px solid ${(D.phase==='checking'||isActive)?t.dim:t.accent}`,
|
||||
color: (D.phase==='checking'||isActive)?t.dim:t.accent, fontFamily: fontMono,
|
||||
fontWeight: 600, fontSize: 11.5, letterSpacing: 1, textAlign:'left',
|
||||
cursor: D.phase==='checking'?'not-allowed':'pointer', borderRadius: 0,
|
||||
textShadow: (D.phase==='checking'||isActive)?'none':`0 0 6px ${t.accent}80`,
|
||||
}}>
|
||||
{D.phase==='checking'
|
||||
? <>{'>'} running…<span style={{ animation:'drv-blink 1s steps(2) infinite' }}>_</span></>
|
||||
: <>{'> check_connection --strict'}</>}
|
||||
</button>
|
||||
|
||||
{/* status */}
|
||||
<div style={{ height: 12 }}/>
|
||||
<div style={cliHead(t)}>┌── status ─────────────────────────────────────────────────</div>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}`, color: t.dim }}>
|
||||
{'> '} ready to check<span style={{ animation: 'drv-blink 1s steps(2) infinite' }}>_</span>
|
||||
</div>
|
||||
: <div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}` }}>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ color: t.dim }}>{'> '} running diagnostics<span style={{ animation:'drv-blink 1s steps(2) infinite' }}>...</span></div>
|
||||
: (D.lastSummary?.failed === 0
|
||||
? <div style={{ color: t.accent, fontWeight: 600 }}>[ ✓ ] all checks passed. ready to start.</div>
|
||||
: <div style={{ color: t.warn, fontWeight: 600 }}>[ ! ] {D.lastSummary?.failed}/{D.tests.length} failed. some features won't work.</div>
|
||||
)}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const sym = state==='passed'?'✓':state==='failed'?'✗':state==='skipped'?'~':state==='running'?'›':'·';
|
||||
const c = state==='passed'?t.pass:state==='failed'?t.danger:state==='skipped'?t.skip:state==='running'?t.accent:t.dimmer;
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display:'flex', gap: 9, alignItems:'baseline', padding:'1px 0' }}>
|
||||
<span style={{ color: c, width: 10 }}>{sym}</span>
|
||||
<span style={{ color: state==='pending'?t.dimmer:t.text, flex: 1 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ color: c, fontSize: 10.5 }}>{r?.metric || (state==='running'?'…':'')}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', padding: 0,
|
||||
}}>{r.expanded?'[−]':'[+]'}</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin:'2px 0 6px 19px', padding:'4px 8px', borderLeft:`2px solid ${t.danger}`,
|
||||
background: 'rgba(255,107,107,0.06)', fontSize: 10.5,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600 }}>! {r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* actions */}
|
||||
<div style={{ height: 12 }}/>
|
||||
<div style={{ display:'flex', gap: 6 }}>
|
||||
{(() => {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const warning = isActive && (D.lastSummary?.failed||0) > 0;
|
||||
if (isActive) {
|
||||
const c = warning ? t.warn : t.accent;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding:'7px 12px', border:`1px solid ${c}`, color: c, fontWeight: 700,
|
||||
background: `${c}14`, textShadow: `0 0 6px ${c}80`,
|
||||
display:'flex', alignItems:'center', gap: 7,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 7, height: 7, borderRadius: 3, background: c, boxShadow:`0 0 6px ${c}` }}/>
|
||||
[ ACTIVE{warning ? ' / UDP-WARN' : ''} ]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'7px 12px', border:`1px solid ${ok?t.accent:t.dim}`,
|
||||
background: ok ? `${t.accent}14` : 'transparent',
|
||||
color: ok ? t.accent : t.dim, fontFamily: fontMono, fontWeight: 600, fontSize: 11.5,
|
||||
letterSpacing: 1, textAlign:'left', cursor: ok?'pointer':'not-allowed',
|
||||
textShadow: ok ? `0 0 6px ${t.accent}80` : 'none', borderRadius: 0,
|
||||
}}>{'>'} start_proxying</button>
|
||||
);
|
||||
})()}
|
||||
<button onClick={D.stopProxy} disabled={!isActive} style={{
|
||||
flex: 1, padding:'7px 12px', border:`1px solid ${isActive?t.dim:t.borderSoft}`,
|
||||
background:'transparent', color: isActive ? t.text : t.dimmer, fontFamily: fontMono,
|
||||
fontWeight: 600, fontSize: 11.5, letterSpacing: 1, textAlign:'left',
|
||||
cursor: isActive?'pointer':'not-allowed', borderRadius: 0,
|
||||
}}>{'■'} stop</button>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 8, padding:'5px 9px', border:`1px dashed ${t.borderSoft}`,
|
||||
color: t.dim, fontSize: 10.5, display:'flex', justifyContent:'space-between',
|
||||
}}>
|
||||
<span>↑ {window.fmtBytes(D.stats.up).padEnd(10)}</span>
|
||||
<span>↓ {window.fmtBytes(D.stats.down).padEnd(10)}</span>
|
||||
<span>tcp={D.stats.tcp}</span>
|
||||
<span>udp={D.stats.udp}</span>
|
||||
<span>up={window.fmtUptime(D.stats.uptimeS)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* logs */}
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0, position:'relative', zIndex: 6 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'6px 14px', display:'flex', alignItems:'center', gap: 8,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontFamily: fontMono, fontSize: 11, letterSpacing: 0.6,
|
||||
}}>
|
||||
<span>{D.logsOpen?'▼':'▶'}</span>
|
||||
<span style={{ fontWeight: 600 }}>tail -f drover.log</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding:'0 14px 6px' }}>
|
||||
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.borderSoft}`, color: t.dim,
|
||||
padding:'2px 7px', fontSize: 10, fontFamily: fontMono, cursor:'pointer', borderRadius: 0,
|
||||
}}>{`[${l}]`}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 110, overflowY:'auto', padding:'5px 14px',
|
||||
fontFamily: fontMono, fontSize: 10, lineHeight: 1.55, color: t.dim }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent }}>{l.level}</span>{' '}
|
||||
<span style={{ color: t.text }}>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cliHead(t) { return { color: t.dim, fontSize: 10.5, marginBottom: 2, letterSpacing: 0.3 }; }
|
||||
function CliRow({ children, style }) {
|
||||
return <div style={{ display:'flex', alignItems:'center', gap: 7, ...style }}>{children}</div>;
|
||||
}
|
||||
function CliInput({ value, onChange, placeholder, type, onSubmit, style, t, id }) {
|
||||
return <input id={id} value={value} type={type||'text'}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: t.accent, border:'none',
|
||||
borderBottom:`1px solid ${t.borderSoft}`, borderRadius: 0,
|
||||
padding:'3px 4px', fontFamily: 'inherit', fontSize: 12,
|
||||
outline:'none', boxSizing:'border-box', textShadow:`0 0 4px ${t.accent}60`, ...style,
|
||||
}}/>;
|
||||
}
|
||||
|
||||
window.CliWindow = CliWindow;
|
||||
@@ -0,0 +1,293 @@
|
||||
// drover-v2.jsx — Round 2: four new dark-only variants.
|
||||
// Reuses window.useDrover / StatusDot / icons / fmt helpers from drover-shared.jsx.
|
||||
|
||||
// =============================================================================
|
||||
// V5 — COMPACT PRO. High-density, table-like. Wireshark/HTOP feel. Mono numbers.
|
||||
// =============================================================================
|
||||
const CPT = {
|
||||
bg: '#101216', chrome: '#0a0c0f', panel: '#15181d', panel2: '#1a1d23',
|
||||
border: '#262a31', borderSoft: '#1d2026',
|
||||
text: '#e1e3e8', dim: '#838892', dimmer: '#535862',
|
||||
accent: '#7aa9ff', danger: '#e5685a', warn: '#d6a64a', pass: '#5fc888', skip: '#6a6f78',
|
||||
inputBg: '#0c0e12', primaryFg: '#0b1426',
|
||||
};
|
||||
|
||||
function CompactWindow({ initial }) {
|
||||
const t = CPT;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Consolas,monospace';
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontUI, fontSize: 12, overflow: 'hidden',
|
||||
border: '1px solid #000',
|
||||
}}>
|
||||
{/* title bar */}
|
||||
<div style={{
|
||||
height: 28, background: t.chrome, borderBottom:`1px solid ${t.border}`,
|
||||
display:'flex', alignItems:'center', userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 7, padding:'0 10px', flex:1 }}>
|
||||
<window.BrandMark size={12} color={t.accent}/>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600 }}>drover-go</span>
|
||||
<span style={{ fontSize: 10, color: t.dimmer, fontFamily: fontMono }}>0.4.2</span>
|
||||
<span style={{ fontSize: 10, color: t.dim, marginLeft: 8, fontFamily: fontMono }}>
|
||||
· {D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<CompactCell t={t}><window.IconGear color={t.dim}/></CompactCell>
|
||||
<CompactCell t={t}><window.IconMin color={t.dim}/></CompactCell>
|
||||
<CompactCell t={t} hover="#c0463f"><window.IconClose color={t.dim}/></CompactCell>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto' }}>
|
||||
{/* Row 1: form, single tight line */}
|
||||
<div style={{ padding: '10px 12px', borderBottom:`1px solid ${t.borderSoft}` }}>
|
||||
<div style={cptHead(t)}>SOCKS5</div>
|
||||
<div style={{ display:'flex', gap: 6, alignItems:'center' }}>
|
||||
<CptInput t={t} fontMono={fontMono} value={D.form.host}
|
||||
onChange={v => D.update({ host: v })} placeholder="host" style={{ flex: 1 }}
|
||||
onSubmit={D.runCheck}/>
|
||||
<span style={{ color: t.dimmer, fontFamily: fontMono }}>:</span>
|
||||
<CptInput t={t} fontMono={fontMono} value={D.form.port}
|
||||
onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
placeholder="port" style={{ width: 64 }} onSubmit={D.runCheck}/>
|
||||
<CptCheck t={t} checked={D.form.auth} onChange={(v) => {
|
||||
D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('cpt-login')?.focus(),30);
|
||||
}}>auth</CptCheck>
|
||||
</div>
|
||||
{D.form.auth && (
|
||||
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
||||
<CptInput id="cpt-login" t={t} fontMono={fontMono} value={D.form.login}
|
||||
onChange={v => D.update({ login: v })} placeholder="login"
|
||||
style={{ flex: 1 }} onSubmit={D.runCheck}/>
|
||||
<CptInput t={t} fontMono={fontMono} value={D.form.password} type="password"
|
||||
onChange={v => D.update({ password: v })} placeholder="password"
|
||||
style={{ flex: 1 }} onSubmit={D.runCheck}/>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={D.runCheck} disabled={D.phase === 'checking' || isActive} style={{
|
||||
marginTop: 8, width:'100%', padding:'6px 10px', borderRadius: 2, fontFamily: fontMono,
|
||||
background: (D.phase==='checking'||isActive)?t.panel2:t.accent,
|
||||
color: (D.phase==='checking'||isActive)?t.dimmer:t.primaryFg,
|
||||
border:`1px solid ${t.border}`, fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5,
|
||||
cursor: D.phase==='checking'?'not-allowed':'pointer', textAlign: 'left',
|
||||
}}>
|
||||
{'>>'} {D.phase==='checking'?'CHECKING…':'check connection'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: status table, full width */}
|
||||
<div style={{ padding: '8px 12px', borderBottom:`1px solid ${t.borderSoft}` }}>
|
||||
<div style={cptHead(t)}>
|
||||
<span>STATUS</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono }}>
|
||||
{D.phase === 'idle' && 'idle'}
|
||||
{D.phase === 'checking' && `${Object.keys(D.results).length}/${D.tests.length}`}
|
||||
{(D.phase === 'checked' || D.phase === 'active') &&
|
||||
(D.lastSummary?.failed === 0 ? 'all-pass' : `${D.lastSummary?.failed}/${D.tests.length} fail`)}
|
||||
</span>
|
||||
</div>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
<span style={{ color: t.dimmer }}>›</span> ready to check_
|
||||
</div>
|
||||
: <CompactStatusTable t={t} D={D} palette={palette} fontMono={fontMono}/>}
|
||||
</div>
|
||||
|
||||
{/* Row 3: actions */}
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
<div style={{ display:'flex', gap: 6 }}>
|
||||
<CompactStartBtn t={t} D={D} fontMono={fontMono}/>
|
||||
<CompactStopBtn t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 8, padding: '5px 8px', background: t.panel,
|
||||
border:`1px solid ${t.borderSoft}`, borderRadius: 2,
|
||||
fontFamily: fontMono, fontSize: 10.5, color: t.dim,
|
||||
display:'flex', justifyContent:'space-between',
|
||||
}}>
|
||||
<span><window.IconArrowUp color={t.pass}/> {window.fmtBytes(D.stats.up)}</span>
|
||||
<span><window.IconArrowDown color={t.accent}/> {window.fmtBytes(D.stats.down)}</span>
|
||||
<span>tcp:{D.stats.tcp}</span>
|
||||
<span>udp:{D.stats.udp}</span>
|
||||
<span>up:{window.fmtUptime(D.stats.uptimeS)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<CompactLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCell({ children, t, hover }) {
|
||||
const [h, setH] = React.useState(false);
|
||||
return <div onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)} style={{
|
||||
width: 32, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
cursor:'pointer', background: h && hover ? hover : 'transparent', transition:'background .1s',
|
||||
}}>{children}</div>;
|
||||
}
|
||||
function cptHead(t) {
|
||||
return {
|
||||
fontSize: 9.5, letterSpacing: 1.5, color: t.dim, fontWeight: 700,
|
||||
marginBottom: 6, display:'flex', alignItems:'center',
|
||||
};
|
||||
}
|
||||
function CptInput({ value, onChange, placeholder, type, style, onSubmit, t, fontMono, id }) {
|
||||
return <input id={id} value={value} type={type||'text'}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: t.text, border:`1px solid ${t.border}`,
|
||||
borderRadius: 2, padding:'5px 7px', fontFamily: fontMono, fontSize: 11.5,
|
||||
outline:'none', boxSizing:'border-box', ...style,
|
||||
}}/>;
|
||||
}
|
||||
function CptCheck({ checked, onChange, t, children }) {
|
||||
return (
|
||||
<label style={{
|
||||
display:'inline-flex', alignItems:'center', gap: 5, cursor:'pointer', userSelect:'none',
|
||||
padding:'4px 7px', border:`1px solid ${checked?t.accent:t.border}`, borderRadius: 2,
|
||||
fontFamily: '"JetBrains Mono",monospace', fontSize: 11,
|
||||
background: checked ? `${t.accent}22` : 'transparent', color: checked ? t.accent : t.dim,
|
||||
}}>
|
||||
<span>[{checked ? 'x' : ' '}]</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{display:'none'}}/>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function CompactStatusTable({ t, D, palette, fontMono }) {
|
||||
return (
|
||||
<div style={{ fontFamily: fontMono, fontSize: 11 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{
|
||||
display:'flex', alignItems:'center', gap: 7, padding:'2px 0', height: 20,
|
||||
color: state === 'pending' ? t.dimmer : t.text,
|
||||
}}>
|
||||
<span style={{ width: 10, color: t.dimmer }}>{
|
||||
state==='passed'?'✓':state==='failed'?'✗':state==='skipped'?'–':state==='running'?'›':'·'
|
||||
}</span>
|
||||
<window.StatusDot state={state} palette={palette} size={10}/>
|
||||
<span title={test.desc}>{test.label}</span>
|
||||
<span style={{
|
||||
marginLeft:'auto',
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:state==='passed'?t.pass:t.dim,
|
||||
}}>{r?.metric || (state==='running'?'…':'')}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 2, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 0 6px 17px', padding:'5px 8px',
|
||||
background: t.panel2, borderLeft:`2px solid ${t.danger}`,
|
||||
fontSize: 10.5, color: t.dim,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function CompactStartBtn({ t, D, fontMono }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex:1, padding:'6px 10px', border:`1px solid ${c}`, borderRadius: 2,
|
||||
background: `${c}1a`, color: c, fontFamily: fontMono, fontWeight: 700, fontSize: 11.5,
|
||||
display:'flex', alignItems:'center', gap: 6,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 6, height: 6, borderRadius: 3, background: c }}/>
|
||||
ACTIVE{warning ? ' · UDP-FALLBACK' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'6px 10px', borderRadius: 2,
|
||||
background: ok ? t.accent : t.panel, color: ok ? t.primaryFg : t.dimmer,
|
||||
border:`1px solid ${ok ? t.accent : t.border}`, fontFamily: fontMono,
|
||||
fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5, textAlign:'left',
|
||||
cursor: ok ? 'pointer' : 'not-allowed',
|
||||
}}>{'>'} start proxying</button>
|
||||
);
|
||||
}
|
||||
function CompactStopBtn({ t, D, fontMono }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding:'6px 10px', borderRadius: 2,
|
||||
background: t.panel, color: enabled ? t.text : t.dimmer,
|
||||
border:`1px solid ${t.border}`, fontFamily: fontMono,
|
||||
fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5, textAlign:'left',
|
||||
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
}}>{'■'} stop</button>
|
||||
);
|
||||
}
|
||||
function CompactLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'6px 12px', display:'flex', alignItems:'center', gap: 7,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontFamily: fontMono, fontSize: 10.5, letterSpacing: 1,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
|
||||
<span style={{ fontWeight: 700 }}>LOGS</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 4, padding:'0 12px 6px' }}>
|
||||
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, color: t.dim,
|
||||
padding:'2px 7px', fontSize: 10, fontFamily: fontMono, cursor:'pointer', borderRadius: 2,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 110, overflowY:'auto', padding:'5px 12px',
|
||||
fontFamily: fontMono, fontSize: 10, lineHeight: 1.55, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 700 }}>{l.level.padEnd(5)}</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CompactWindow = CompactWindow;
|
||||
@@ -0,0 +1,416 @@
|
||||
// drover-fluent-live.jsx — Fluent / Win11 live variant.
|
||||
// System-native: Segoe UI Variable, acrylic-blue (#0067c0), light cards on a tinted bg.
|
||||
|
||||
const FLUENT_THEME = {
|
||||
l: {
|
||||
bg: '#f3f3f3',
|
||||
chrome: '#fafafa',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f7f7f7',
|
||||
border: 'rgba(0,0,0,.06)',
|
||||
borderHard:'#d6d6d6',
|
||||
text: '#1a1a1a',
|
||||
dim: '#666666',
|
||||
dimmer: '#999999',
|
||||
accent: '#0067c0',
|
||||
accentHover:'#005a9e',
|
||||
danger: '#c42b1c',
|
||||
warn: '#9d5d00',
|
||||
pass: '#107c10',
|
||||
skip: '#888888',
|
||||
inputBg: '#fafafa',
|
||||
inputBorder:'#888',
|
||||
activeBg: '#dff6dd',
|
||||
activeBorder:'#9bc99b',
|
||||
warnBg: '#fff4ce',
|
||||
warnBorder:'#dba81a',
|
||||
},
|
||||
d: {
|
||||
bg: '#202020',
|
||||
chrome: '#181818',
|
||||
panel: '#2b2b2b',
|
||||
panelAlt: '#252525',
|
||||
border: 'rgba(255,255,255,.06)',
|
||||
borderHard:'#3a3a3a',
|
||||
text: '#f0f0f0',
|
||||
dim: '#9b9b9b',
|
||||
dimmer: '#6a6a6a',
|
||||
accent: '#4cc2ff',
|
||||
accentHover:'#6cd0ff',
|
||||
danger: '#ff6b6b',
|
||||
warn: '#fce100',
|
||||
pass: '#6ccb5f',
|
||||
skip: '#7a7a7a',
|
||||
inputBg: '#2c2c2c',
|
||||
inputBorder:'#666',
|
||||
activeBg: '#1d2f1c',
|
||||
activeBorder:'#3d6e3a',
|
||||
warnBg: '#3a3017',
|
||||
warnBorder:'#7a6320',
|
||||
},
|
||||
};
|
||||
|
||||
function FluentWindow({ mode = 'light', initial }) {
|
||||
const themeKey = mode === 'dark' ? 'd' : 'l';
|
||||
const t = FLUENT_THEME[themeKey];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = "'Segoe UI Variable','Segoe UI',system-ui,-apple-system,sans-serif";
|
||||
const fontMono = "'Cascadia Mono','Cascadia Code',Consolas,'JetBrains Mono',ui-monospace,monospace";
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text,
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.4,
|
||||
borderRadius: 0,
|
||||
}}>
|
||||
<FluentTitleBar t={t} mode={mode} />
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '12px 12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* SOCKS5 form card */}
|
||||
<FluentCard t={t}>
|
||||
<FluentCardTitle t={t}>SOCKS5 Proxy</FluentCardTitle>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<FluentField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host}
|
||||
onChange={e => D.update({ host: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
style={fluentInputStyle(t, fontMono)} />
|
||||
</FluentField>
|
||||
<FluentField t={t} label="Port" style={{ width: 92 }}>
|
||||
<input value={D.form.port}
|
||||
onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
style={fluentInputStyle(t, fontMono)} />
|
||||
</FluentField>
|
||||
</div>
|
||||
<FluentCheckbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('flu-login')?.focus(), 30); }}>
|
||||
Authentication
|
||||
</FluentCheckbox>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, opacity: D.form.auth ? 1 : 0.45, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
|
||||
<FluentField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="flu-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="user" style={fluentInputStyle(t, fontMono, !D.form.auth)} />
|
||||
</FluentField>
|
||||
<FluentField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="••••••" style={fluentInputStyle(t, fontMono, !D.form.auth)} />
|
||||
</FluentField>
|
||||
</div>
|
||||
<FluentPrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</FluentPrimaryBtn>
|
||||
</FluentCard>
|
||||
|
||||
{/* Status card */}
|
||||
<FluentCard t={t}>
|
||||
<FluentCardTitle t={t}>Status</FluentCardTitle>
|
||||
<FluentStatus t={t} D={D} palette={palette} fontMono={fontMono} themeKey={themeKey} />
|
||||
</FluentCard>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<FluentStartBtn t={t} D={D} fontMono={fontMono} />
|
||||
<FluentStopBtn t={t} D={D} />
|
||||
</div>
|
||||
|
||||
{isActive && <FluentLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
|
||||
|
||||
<div style={{ height: 8 }} />
|
||||
</div>
|
||||
|
||||
<FluentLogs t={t} D={D} fontMono={fontMono} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentTitleBar({ t, mode }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, background: t.chrome, borderBottom: `1px solid ${t.border}`,
|
||||
display: 'flex', alignItems: 'center',
|
||||
fontFamily: "'Segoe UI Variable','Segoe UI',system-ui,sans-serif",
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', flex: 1 }}>
|
||||
<window.BrandMark size={14} color={t.accent} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<FluentTitleBtn t={t}><window.IconGear color={t.dim} /></FluentTitleBtn>
|
||||
<FluentTitleBtn t={t}><window.IconMin color={t.dim} /></FluentTitleBtn>
|
||||
<FluentTitleBtn t={t} hoverBg="#c42b1c" hoverFg="#fff"><window.IconClose color={t.dim} /></FluentTitleBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function FluentTitleBtn({ children, t, hoverBg, hoverFg }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{ width: 46, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
background: hover ? (hoverBg || 'rgba(127,127,127,.12)') : 'transparent',
|
||||
color: hover && hoverFg ? hoverFg : 'inherit',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentCard({ t, children, style }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 6,
|
||||
padding: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||
display: 'flex', flexDirection: 'column', gap: 8, ...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
function FluentCardTitle({ t, children }) {
|
||||
return <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, letterSpacing: 0.2 }}>{children}</div>;
|
||||
}
|
||||
function FluentField({ t, label, children, style }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2, ...style }}>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function fluentInputStyle(t, fontMono, disabled) {
|
||||
return {
|
||||
height: 30, background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.border}`, borderBottom: `1.5px solid ${t.inputBorder}`,
|
||||
borderRadius: 4, padding: '0 10px',
|
||||
fontFamily: fontMono, fontSize: 12.5, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s',
|
||||
};
|
||||
}
|
||||
function FluentCheckbox({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 12.5, marginTop: 2 }}>
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 4,
|
||||
border: `1.5px solid ${checked ? t.accent : t.borderHard}`,
|
||||
background: checked ? t.accent : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background .12s, border-color .12s',
|
||||
}}>
|
||||
{checked && <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display: 'none' }} />
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function FluentPrimaryBtn({ t, onClick, disabled, children, style }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
height: 34, width: '100%', borderRadius: 4, border: 'none',
|
||||
background: disabled ? t.panelAlt : (hover ? t.accentHover : t.accent),
|
||||
color: disabled ? t.dimmer : '#fff', fontWeight: 600, fontSize: 13,
|
||||
boxShadow: disabled ? 'none' : 'inset 0 -1px 0 rgba(0,0,0,.12)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'background .12s', marginTop: 4, ...style,
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentStatus({ t, D, palette, fontMono, themeKey }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', color: t.dim, fontSize: 12.5 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }} />
|
||||
<span>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5,
|
||||
color: D.phase === 'checking' ? t.accent
|
||||
: D.lastSummary?.failed === 0 ? t.pass : t.warn,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<window.StatusDot state="running" palette={palette} size={12} />
|
||||
<span>Running diagnostics…</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dim, fontFamily: fontMono, fontSize: 11, fontWeight: 400 }}>
|
||||
{Object.keys(D.results).length}/{D.tests.length}
|
||||
</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? <span>All checks passed. Ready to start.</span>
|
||||
: <span>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, marginTop: 4 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 2px', minHeight: 22 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={12} />
|
||||
<span style={{ fontSize: 12.5, color: state === 'pending' ? t.dimmer : t.text }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '…' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)}
|
||||
style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 0 4px 21px', padding: '8px 10px', borderRadius: 4,
|
||||
background: themeKey === 'l' ? '#fdf2f2' : '#3a1f1f',
|
||||
border: `1px solid ${t.danger}`,
|
||||
fontSize: 11.5,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
|
||||
style={{ marginTop: 6, padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: t.panelAlt, color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 3, cursor: 'pointer' }}>
|
||||
copy error
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentStartBtn({ t, D, fontMono }) {
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (summary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, height: 34, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
background: warning ? t.warnBg : t.activeBg,
|
||||
border: `1px solid ${warning ? t.warnBorder : t.activeBorder}`,
|
||||
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass }} />
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FluentPrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1, marginTop: 0 }}>
|
||||
Start proxying
|
||||
</FluentPrimaryBtn>
|
||||
);
|
||||
}
|
||||
function FluentStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
flex: 1, height: 34, borderRadius: 4, fontWeight: 600, fontSize: 12.5,
|
||||
background: enabled && hover ? t.panelAlt : t.panel,
|
||||
color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.borderHard}`, cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
transition: 'background .12s',
|
||||
}}>Stop</button>
|
||||
);
|
||||
}
|
||||
function FluentLiveStats({ t, stats, fontMono }) {
|
||||
const cell = (label, val) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: t.dim, fontSize: 11.5, fontFamily: fontMono }}>
|
||||
{label}<span style={{ color: t.text }}>{val}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 12px', borderRadius: 4,
|
||||
background: t.panel, border: `1px solid ${t.border}`,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
{cell(<window.IconArrowUp color={t.dim} />, window.fmtBytes(stats.up))}
|
||||
{cell(<window.IconArrowDown color={t.dim} />, window.fmtBytes(stats.down))}
|
||||
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>TCP</span>, stats.tcp)}
|
||||
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>UDP</span>, stats.udp)}
|
||||
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>↑t</span>, window.fmtUptime(stats.uptimeS))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width: '100%', padding: '8px 14px', display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'transparent', border: 'none', color: t.dim, cursor: 'pointer',
|
||||
fontSize: 11.5, fontFamily: fontMono,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'} />
|
||||
<span>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<div style={{ borderTop: `1px solid ${t.border}` }}>
|
||||
<div style={{ display: 'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.border}` }}>
|
||||
{[
|
||||
['Copy all', () => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))],
|
||||
['Clear', D.clearLogs],
|
||||
['Open log file', () => {}],
|
||||
].map(([l, fn], i) => (
|
||||
<button key={i} onClick={fn} style={{
|
||||
padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: t.panel, color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 3, cursor: 'pointer',
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
|
||||
background: t.panelAlt,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.FluentWindow = FluentWindow;
|
||||
@@ -0,0 +1,348 @@
|
||||
// drover-glass.jsx — Variant 3: Glassmorphism.
|
||||
// Soft animated gradient backdrop. Frosted blur on every panel. Subtle glow on active.
|
||||
|
||||
const GlassTheme = {
|
||||
d: {
|
||||
bgGrad: 'radial-gradient(120% 80% at 0% 0%, #2b1d4a 0%, #131326 50%, #0d1024 100%)',
|
||||
glassBg: 'rgba(255,255,255,0.06)',
|
||||
glassBg2: 'rgba(255,255,255,0.10)',
|
||||
border: 'rgba(255,255,255,0.14)',
|
||||
borderSoft: 'rgba(255,255,255,0.08)',
|
||||
text: '#f3f1ff', dim: '#bdbcd6', dimmer: '#7e7d99',
|
||||
accent: '#9b8bff', accentGlow: '#9b8bff',
|
||||
danger: '#ff8a98', warn: '#ffc46b', pass: '#7ee0b3', skip: '#9aa0aa',
|
||||
inputBg: 'rgba(0,0,0,0.18)', primaryFg: '#1a1538',
|
||||
},
|
||||
l: {
|
||||
bgGrad: 'radial-gradient(110% 70% at 0% 0%, #c8d8ff 0%, #f0e8ff 55%, #ffeaf2 100%)',
|
||||
glassBg: 'rgba(255,255,255,0.55)',
|
||||
glassBg2: 'rgba(255,255,255,0.75)',
|
||||
border: 'rgba(255,255,255,0.7)',
|
||||
borderSoft: 'rgba(255,255,255,0.5)',
|
||||
text: '#1a1538', dim: '#5e5b7a', dimmer: '#8e8cab',
|
||||
accent: '#5e4bcf', accentGlow: '#9b8bff',
|
||||
danger: '#c0463f', warn: '#a06a14', pass: '#2c8a5a', skip: '#8a8aa0',
|
||||
inputBg: 'rgba(255,255,255,0.6)', primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
function GlassWindow({ mode = 'dark', initial }) {
|
||||
const t = GlassTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const fontMono = '"JetBrains Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13, position: 'relative',
|
||||
background: t.bgGrad, overflow: 'hidden', borderRadius: 0,
|
||||
}}>
|
||||
{/* gradient orbs */}
|
||||
<div style={{ position:'absolute', width: 280, height: 280, borderRadius:'50%',
|
||||
background: 'radial-gradient(closest-side, rgba(155,139,255,0.55), transparent)',
|
||||
top: -80, right: -80, filter: 'blur(20px)', pointerEvents:'none' }}/>
|
||||
<div style={{ position:'absolute', width: 240, height: 240, borderRadius:'50%',
|
||||
background: mode==='dark' ? 'radial-gradient(closest-side, rgba(126,224,179,0.35), transparent)'
|
||||
: 'radial-gradient(closest-side, rgba(255,180,210,0.55), transparent)',
|
||||
bottom: -90, left: -60, filter: 'blur(30px)', pointerEvents:'none' }}/>
|
||||
|
||||
{/* title */}
|
||||
<GlassTitle t={t}/>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding: '10px 14px 0', position:'relative', zIndex: 1 }}>
|
||||
<GlassPanel t={t}>
|
||||
<GlassHeader t={t}>SOCKS5 Proxy</GlassHeader>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<GField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
|
||||
</GField>
|
||||
<GField t={t} label="Port" style={{ width: 96 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
|
||||
</GField>
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
<GlassToggle t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('gl-login')?.focus(),30); }}>
|
||||
Authentication
|
||||
</GlassToggle>
|
||||
<div style={{ display:'flex', gap: 10, marginTop: 10, opacity: D.form.auth ? 1 : 0.4,
|
||||
pointerEvents: D.form.auth ? 'auto':'none' }}>
|
||||
<GField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="gl-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
|
||||
</GField>
|
||||
<GField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
|
||||
</GField>
|
||||
</div>
|
||||
<div style={{ height: 14 }}/>
|
||||
<GlassPrimary t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</GlassPrimary>
|
||||
</GlassPanel>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<GlassPanel t={t}>
|
||||
<GlassHeader t={t}>Status</GlassHeader>
|
||||
<GlassStatus t={t} D={D} palette={palette} fontMono={fontMono}/>
|
||||
</GlassPanel>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<GlassPanel t={t}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<GlassStartBtn t={t} D={D}/>
|
||||
<GlassStopBtn t={t} D={D}/>
|
||||
</div>
|
||||
{isActive && <GlassLiveStats t={t} stats={D.stats} fontMono={fontMono}/>}
|
||||
</GlassPanel>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
|
||||
<GlassLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlassTitle({ t }) {
|
||||
const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
|
||||
return (
|
||||
<div style={{
|
||||
height: 36, background: t.glassBg, backdropFilter: 'blur(20px)',
|
||||
borderBottom: `1px solid ${t.borderSoft}`, display:'flex', alignItems:'center', position:'relative', zIndex: 2,
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, padding:'0 14px', flex:1 }}>
|
||||
<window.BrandMark size={15} color={t.accent}/>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell}><window.IconGear color={t.dim}/></div>
|
||||
<div style={cell}><window.IconMin color={t.dim}/></div>
|
||||
<div style={cell}><window.IconClose color={t.dim}/></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlassPanel({ t, children, style }) {
|
||||
return <div style={{
|
||||
background: t.glassBg, backdropFilter: 'blur(24px) saturate(140%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(140%)',
|
||||
border: `1px solid ${t.border}`, borderRadius: 14, padding: 14,
|
||||
boxShadow: `inset 0 1px 0 ${t.borderSoft}`,
|
||||
...style,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
function GlassHeader({ t, children }) {
|
||||
return <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, marginBottom: 10, letterSpacing: 1.5, textTransform:'uppercase' }}>{children}</div>;
|
||||
}
|
||||
function GField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 11, color: t.dim }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function glassInput(t, fontUI, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.borderSoft}`, borderRadius: 8, padding: '8px 11px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
backdropFilter: 'blur(8px)',
|
||||
};
|
||||
}
|
||||
function GlassToggle({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 10, cursor:'pointer' }}>
|
||||
<span style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, boxSizing:'border-box',
|
||||
background: checked ? t.accent : 'rgba(255,255,255,0.18)',
|
||||
boxShadow: checked ? `0 0 12px ${t.accentGlow}80` : 'none',
|
||||
transition: 'background .14s, box-shadow .14s', display:'flex', alignItems:'center',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 7, background: 'white',
|
||||
transform: checked ? 'translateX(16px)' : 'translateX(0)', transition: 'transform .14s',
|
||||
}}/>
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 13 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function GlassPrimary({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} style={{
|
||||
width:'100%', padding: '10px 14px', border:'none', borderRadius: 10,
|
||||
background: disabled ? 'rgba(255,255,255,0.08)' : `linear-gradient(135deg, ${t.accent}, ${t.accentGlow})`,
|
||||
color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 13, cursor: disabled?'not-allowed':'pointer',
|
||||
boxShadow: disabled ? 'none' : `0 6px 24px ${t.accentGlow}55, inset 0 1px 0 rgba(255,255,255,0.4)`,
|
||||
...style,
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
function GlassStatus({ t, D, palette, fontMono }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const allOk = D.lastSummary?.failed === 0;
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 9, marginBottom: 8 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding: '7px 11px', borderRadius: 8, marginBottom: 10,
|
||||
background: allOk ? 'rgba(126,224,179,0.16)' : 'rgba(255,196,107,0.18)',
|
||||
border: `1px solid ${allOk ? t.pass : t.warn}55`,
|
||||
color: allOk ? t.pass : t.warn, fontWeight: 600, fontSize: 12.5,
|
||||
}}>{allOk ? 'All checks passed. Ready to start.' : `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}</div>}
|
||||
<div>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id} style={{ padding:'4px 0' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 26 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={14}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim }}>
|
||||
{r?.metric}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 23px', padding: 10, borderRadius: 8,
|
||||
background: 'rgba(255,138,152,0.10)', border: `1px solid ${t.danger}40`,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function GlassStartBtn({ t, D }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const checkedOk = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 10, fontWeight: 600, fontSize: 13,
|
||||
background: warning ? 'rgba(255,196,107,0.16)' : 'rgba(126,224,179,0.16)',
|
||||
border: `1px solid ${c}66`, color: c,
|
||||
boxShadow: `0 0 24px ${c}40, inset 0 1px 0 rgba(255,255,255,0.18)`,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 9,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: c, boxShadow: `0 0 8px ${c}` }}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <GlassPrimary t={t} onClick={D.startProxy} disabled={!checkedOk} style={{ flex: 1 }}>Start proxying</GlassPrimary>;
|
||||
}
|
||||
function GlassStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.08)', color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.borderSoft}`, fontWeight: 600, fontSize: 13,
|
||||
cursor: enabled ? 'pointer':'not-allowed', backdropFilter: 'blur(8px)',
|
||||
}}>Stop</button>
|
||||
);
|
||||
}
|
||||
function GlassLiveStats({ t, stats, fontMono }) {
|
||||
const C = ({ icon, val, lbl }) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}<span style={{ fontFamily: fontMono, fontSize: 11.5 }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 9.5, color: t.dimmer, textTransform:'uppercase', letterSpacing:0.5 }}>{lbl}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 11, paddingTop: 11, borderTop: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', justifyContent:'space-between', color: t.dim,
|
||||
}}>
|
||||
<C icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(stats.up)}/>
|
||||
<C icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(stats.down)}/>
|
||||
<C val={stats.tcp} lbl="tcp"/>
|
||||
<C val={stats.udp} lbl="udp"/>
|
||||
<C val={window.fmtUptime(stats.uptimeS)} lbl="up"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function GlassLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{
|
||||
borderTop: `1px solid ${t.borderSoft}`,
|
||||
background: t.glassBg, backdropFilter: 'blur(20px)',
|
||||
flexShrink: 0, position:'relative', zIndex: 2,
|
||||
}}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '9px 16px', display:'flex', alignItems:'center', gap:10,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ fontWeight: 600, letterSpacing: 0.5 }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding: '0 16px 8px' }}>
|
||||
{['Copy all','Clear','Open log file'].map((l,i) => (
|
||||
<button key={l} onClick={i===0 ? () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n')) : i===1 ? D.clearLogs : undefined}
|
||||
style={{
|
||||
background:'rgba(255,255,255,0.05)', border:`1px solid ${t.borderSoft}`,
|
||||
borderRadius: 6, padding: '4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY: 'auto', padding: '8px 16px',
|
||||
fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.GlassWindow = GlassWindow;
|
||||
@@ -0,0 +1,424 @@
|
||||
// drover-hero-live.jsx — Big toggle / VPN-style variant.
|
||||
// Centerpiece: a giant connect button. Form lives in an expandable section below.
|
||||
// Compact form (host:port) shown collapsed in active state; full form in idle.
|
||||
|
||||
const HERO_THEME = {
|
||||
d: {
|
||||
bg: 'radial-gradient(ellipse at top, #142340 0%, #0e1426 60%, #080d1c 100%)',
|
||||
chrome: 'transparent',
|
||||
panel: '#15192a',
|
||||
panelAlt: '#1a1f33',
|
||||
border: 'rgba(255,255,255,.08)',
|
||||
borderHard:'rgba(255,255,255,.18)',
|
||||
text: '#ffffff',
|
||||
textDim: '#cbd1d9',
|
||||
dim: '#7a8499',
|
||||
dimmer: '#5a6178',
|
||||
accent: '#5dd4b3',
|
||||
accentDeep:'#2da085',
|
||||
accentDarker:'#1a6e5b',
|
||||
danger: '#ff6b6b',
|
||||
warn: '#f3c764',
|
||||
pass: '#5dd4b3',
|
||||
skip: '#7a8499',
|
||||
},
|
||||
l: {
|
||||
bg: 'linear-gradient(180deg, #f3f7fb 0%, #e8edf4 100%)',
|
||||
chrome: 'transparent',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f5f7fb',
|
||||
border: 'rgba(0,0,0,.07)',
|
||||
borderHard:'rgba(0,0,0,.15)',
|
||||
text: '#0e1830',
|
||||
textDim: '#3a4356',
|
||||
dim: '#6a7383',
|
||||
dimmer: '#9aa3b2',
|
||||
accent: '#1aa787',
|
||||
accentDeep:'#0f8c70',
|
||||
accentDarker:'#0a6655',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#1aa787',
|
||||
skip: '#7a8499',
|
||||
},
|
||||
};
|
||||
|
||||
function HeroWindow({ mode = 'dark', initial }) {
|
||||
const themeKey = mode === 'dark' ? 'd' : 'l';
|
||||
const t = HERO_THEME[themeKey];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = "'Inter','Segoe UI',system-ui,sans-serif";
|
||||
const fontMono = "'JetBrains Mono','SF Mono',ui-monospace,Consolas,monospace";
|
||||
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const failed = summary?.failed ?? 0;
|
||||
const isActive = phase === 'active';
|
||||
const warning = isActive && failed > 0;
|
||||
|
||||
// big-button click handler. Two-step: if idle/checked, go.
|
||||
const handleBigClick = () => {
|
||||
if (phase === 'active') return D.stopProxy();
|
||||
if (phase === 'checking') return;
|
||||
if (phase === 'idle') {
|
||||
// run check, then auto-start if it passed
|
||||
(async () => {
|
||||
await D.runCheck();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (phase === 'checked' && !allFailed) D.startProxy();
|
||||
if (phase === 'checked' && allFailed) D.runCheck();
|
||||
};
|
||||
|
||||
// After runCheck completes from idle, auto-start if all passed.
|
||||
const prevPhase = React.useRef(phase);
|
||||
React.useEffect(() => {
|
||||
if (prevPhase.current === 'checking' && phase === 'checked') {
|
||||
const sum = D.lastSummary;
|
||||
if (sum && sum.failed === 0) {
|
||||
// small delay so user perceives the "checked" state briefly
|
||||
setTimeout(() => D.startProxy(), 600);
|
||||
}
|
||||
}
|
||||
prevPhase.current = phase;
|
||||
}, [phase]);
|
||||
|
||||
// Big-button label / colors
|
||||
let buttonState;
|
||||
if (phase === 'active') buttonState = warning ? 'active-warn' : 'active';
|
||||
else if (phase === 'checking') buttonState = 'checking';
|
||||
else if (phase === 'checked' && allFailed) buttonState = 'failed';
|
||||
else buttonState = 'idle';
|
||||
|
||||
const ringColor = ({
|
||||
idle: t.accent,
|
||||
checking: t.accent,
|
||||
active: t.accent,
|
||||
'active-warn': t.warn,
|
||||
failed: t.danger,
|
||||
})[buttonState];
|
||||
|
||||
const headline = ({
|
||||
idle: 'Tap to connect',
|
||||
checking: 'Verifying…',
|
||||
active: 'Discord is protected',
|
||||
'active-warn': 'Connected · UDP fallback',
|
||||
failed: 'Connection failed',
|
||||
})[buttonState];
|
||||
|
||||
const subline = ({
|
||||
idle: `via ${D.form.host}`,
|
||||
checking: `running 7 checks…`,
|
||||
active: `via ${D.form.host} · ${window.fmtUptime(D.stats.uptimeS)}`,
|
||||
'active-warn': `voice/screen disabled · ${window.fmtUptime(D.stats.uptimeS)}`,
|
||||
failed: `${failed} of ${D.tests.length} checks failed`,
|
||||
})[buttonState];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text,
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.4,
|
||||
}}>
|
||||
{/* Title bar */}
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontSize: 12.5 }}>
|
||||
<window.BrandMark size={14} color={t.accent} />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600, color: t.text }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: t.dim }}>0.4.2</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: ringColor, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{phase === 'active' && <span className="drv-pulsedot" style={{ width: 6, height: 6, borderRadius: 3, background: ringColor }} />}
|
||||
{phase === 'idle' && 'idle'}
|
||||
{phase === 'checking' && 'checking…'}
|
||||
{phase === 'checked' && (allFailed ? 'failed' : 'ready')}
|
||||
{phase === 'active' && (warning ? 'connected · warn' : 'connected')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', marginLeft: 8 }}>
|
||||
<HeroTitleBtn t={t}><window.IconGear color={t.dim} /></HeroTitleBtn>
|
||||
<HeroTitleBtn t={t}><window.IconMin color={t.dim} /></HeroTitleBtn>
|
||||
<HeroTitleBtn t={t} hoverBg="#c42b1c"><window.IconClose color={t.dim} /></HeroTitleBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Big button + headlines */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 18px 0', gap: 14, minHeight: 0 }}>
|
||||
<BigToggle t={t} state={buttonState} onClick={handleBigClick} progress={
|
||||
phase === 'checking' ? Object.keys(D.results).length / D.tests.length : 0
|
||||
} />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 600 }}>{headline}</div>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4, fontFamily: fontMono }}>{subline}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats — only when active */}
|
||||
{isActive && (
|
||||
<div style={{
|
||||
display: 'flex', width: '100%', background: t.panel,
|
||||
border: `1px solid ${t.border}`, borderRadius: 12, padding: 12, justifyContent: 'space-around',
|
||||
}}>
|
||||
<HeroStat icon="↑" v={fmtCompact(D.stats.up)} u={fmtUnit(D.stats.up)} c={t.accent} mono={fontMono} t={t} />
|
||||
<HeroStat icon="↓" v={fmtCompact(D.stats.down)} u={fmtUnit(D.stats.down)} c={t.accent} mono={fontMono} t={t} />
|
||||
<HeroStat icon="◇" v={D.stats.tcp} u="tcp" c={t.text} mono={fontMono} t={t} />
|
||||
<HeroStat icon="◈" v={D.stats.udp} u="udp" c={warning ? t.warn : t.text} mono={fontMono} t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnostic mini-list during check or after */}
|
||||
{(phase === 'checking' || (phase === 'checked' && !isActive)) && (
|
||||
<div style={{ width: '100%', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 8px', minHeight: 24, fontSize: 12.5 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={11} />
|
||||
<span style={{ color: state === 'pending' ? t.dimmer : t.textDim }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '…' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)}
|
||||
style={{ width: 18, height: 18, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 8px 4px 28px', padding: '8px 10px', borderRadius: 6,
|
||||
background: themeKey === 'd' ? 'rgba(255,107,107,.08)' : '#fdf2f2',
|
||||
border: `1px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: form (idle) or footer chips (active) */}
|
||||
<div style={{ padding: '0 18px 14px' }}>
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<button onClick={() => setShowForm(s => !s)}
|
||||
style={{
|
||||
width: '100%', padding: '11px 14px', display: 'flex', alignItems: 'center', gap: 10,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', color: t.text,
|
||||
fontSize: 12.5, fontFamily: fontUI, textAlign: 'left',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 26, height: 26, borderRadius: 13, background: t.panelAlt,
|
||||
border: `1px solid ${t.border}`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: fontMono, fontSize: 10, color: t.accent,
|
||||
}}>S5</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Proxy</div>
|
||||
<div style={{ fontSize: 11, color: t.dim, fontFamily: fontMono }}>{D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}</div>
|
||||
</div>
|
||||
<window.IconChevron color={t.dim} dir={showForm ? 'up' : 'down'} />
|
||||
</button>
|
||||
{showForm && (
|
||||
<div className="drv-fadein" style={{ borderTop: `1px solid ${t.border}`, padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<HeroInput t={t} fontMono={fontMono} value={D.form.host}
|
||||
onChange={v => D.update({ host: v })}
|
||||
onEnter={D.runCheck} placeholder="95.165.72.59 или example.com" label="Host" style={{ flex: 1 }} />
|
||||
<HeroInput t={t} fontMono={fontMono} value={D.form.port}
|
||||
onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
onEnter={D.runCheck} placeholder="12334" label="Port" style={{ width: 90 }} />
|
||||
</div>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 12.5 }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4,
|
||||
border: `1.5px solid ${D.form.auth ? t.accent : t.borderHard}`,
|
||||
background: D.form.auth ? t.accent : 'transparent',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background .12s',
|
||||
}}>
|
||||
{D.form.auth && <svg width="10" height="10" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke={themeKey === 'd' ? '#0c1a18' : '#fff'} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={D.form.auth}
|
||||
onChange={e => { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('hero-login')?.focus(), 30); }}
|
||||
style={{ display: 'none' }} />
|
||||
<span style={{ color: t.textDim }}>Authentication</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
|
||||
<HeroInput t={t} fontMono={fontMono} id="hero-login" value={D.form.login}
|
||||
onChange={v => D.update({ login: v })} onEnter={D.runCheck}
|
||||
placeholder="user" label="Login" style={{ flex: 1 }} disabled={!D.form.auth} />
|
||||
<HeroInput t={t} fontMono={fontMono} value={D.form.password} type="password"
|
||||
onChange={v => D.update({ password: v })} onEnter={D.runCheck}
|
||||
placeholder="••••••" label="Password" style={{ flex: 1 }} disabled={!D.form.auth} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs strip */}
|
||||
<button onClick={() => setShowDetails(s => !s)}
|
||||
style={{
|
||||
width: '100%', marginTop: 8, padding: '6px 4px', display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', color: t.dim,
|
||||
fontSize: 11, fontFamily: fontMono, textAlign: 'left',
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={showDetails ? 'down' : 'right'} />
|
||||
<span>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="drv-fadein" style={{
|
||||
background: t.panelAlt, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
maxHeight: 110, overflow: 'auto', padding: '8px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, color: t.textDim,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Big toggle button ──────────────────────────────────────────────────
|
||||
function BigToggle({ t, state, onClick, progress = 0 }) {
|
||||
// size = 168 px circle; outer pulsing rings
|
||||
const isActive = state === 'active' || state === 'active-warn';
|
||||
const isChecking = state === 'checking';
|
||||
const isFailed = state === 'failed';
|
||||
const isWarn = state === 'active-warn';
|
||||
|
||||
const accent = isWarn ? t.warn : isFailed ? t.danger : t.accent;
|
||||
const accentDeep = isWarn ? t.warn : isFailed ? t.danger : t.accentDeep;
|
||||
const accentDarker = isWarn ? t.warn : isFailed ? t.danger : t.accentDarker;
|
||||
|
||||
const ring = 168;
|
||||
const stroke = 5;
|
||||
const r = (ring - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} disabled={isChecking}
|
||||
style={{
|
||||
position: 'relative', width: ring, height: ring, marginTop: 8,
|
||||
background: isActive
|
||||
? `radial-gradient(circle at 35% 30%, ${accent} 0%, ${accentDeep} 60%, ${accentDarker} 100%)`
|
||||
: isFailed
|
||||
? `radial-gradient(circle at 35% 30%, ${t.danger} 0%, #8c2c2c 100%)`
|
||||
: `radial-gradient(circle at 35% 30%, ${t.panel} 0%, ${t.panelAlt} 100%)`,
|
||||
boxShadow: isActive
|
||||
? `0 0 60px ${accent}66, 0 0 0 6px ${accent}22, 0 0 0 12px ${accent}10`
|
||||
: `0 0 0 2px ${accent}22, 0 6px 24px rgba(0,0,0,.15)`,
|
||||
border: 'none', borderRadius: '50%', cursor: isChecking ? 'wait' : 'pointer',
|
||||
color: isActive || isFailed ? '#fff' : accent,
|
||||
transition: 'background .35s, box-shadow .35s, color .2s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column',
|
||||
}}>
|
||||
{/* pulsing ring on active */}
|
||||
{isActive && (
|
||||
<span style={{
|
||||
position: 'absolute', inset: -16, borderRadius: '50%',
|
||||
border: `1px solid ${accent}66`, animation: 'drv-pulse-ring 2s ease-out infinite',
|
||||
}} />
|
||||
)}
|
||||
{/* progress ring while checking */}
|
||||
{isChecking && (
|
||||
<svg width={ring} height={ring} style={{ position: 'absolute', inset: 0, transform: 'rotate(-90deg)' }}>
|
||||
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={`${accent}30`} strokeWidth={stroke} />
|
||||
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={accent} strokeWidth={stroke} strokeLinecap="round"
|
||||
strokeDasharray={c} strokeDashoffset={c * (1 - progress)}
|
||||
style={{ transition: 'stroke-dashoffset .35s' }} />
|
||||
{/* spinner segment */}
|
||||
<circle cx={ring/2} cy={ring/2} r={r-12} fill="none" stroke={accent} strokeWidth="2" strokeLinecap="round"
|
||||
strokeDasharray={`${(c-24)*0.18} ${(c-24)*0.82}`}
|
||||
style={{ animation: 'drv-spin 1.2s linear infinite', transformOrigin: '50% 50%' }} />
|
||||
</svg>
|
||||
)}
|
||||
<span style={{ fontSize: 42, lineHeight: 1, fontWeight: 100 }}>⏻</span>
|
||||
<span style={{ fontSize: 11, letterSpacing: '.25em', marginTop: 6, opacity: 0.95, fontWeight: 600 }}>
|
||||
{isActive ? 'ACTIVE' : isChecking ? 'TESTING' : isFailed ? 'RETRY' : 'OFF'}
|
||||
</span>
|
||||
<style>{`
|
||||
@keyframes drv-pulse-ring { 0%{ transform: scale(1); opacity:.5; } 70%{ transform: scale(1.18); opacity:0;} 100%{transform:scale(1.18); opacity:0;}}
|
||||
`}</style>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroStat({ icon, v, u, c, mono, t }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', minWidth: 56 }}>
|
||||
<div style={{ fontSize: 11, color: t.accent }}>{icon}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: mono, marginTop: 2, color: c }}>{v}</div>
|
||||
<div style={{ fontSize: 9.5, color: t.dim, letterSpacing: '.06em', textTransform: 'uppercase' }}>{u}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroTitleBtn({ children, t, hoverBg }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 26, height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', borderRadius: 5,
|
||||
background: hover ? (hoverBg || 'rgba(127,127,127,.16)') : 'transparent',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroInput({ t, fontMono, value, onChange, onEnter, placeholder, label, style, type, disabled, id }) {
|
||||
const [focus, setFocus] = React.useState(false);
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }}>{label}</span>
|
||||
<input id={id} type={type} value={value} disabled={disabled}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && onEnter?.()}
|
||||
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
height: 32, background: t.panelAlt, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${focus ? t.accent : t.border}`,
|
||||
borderRadius: 7, padding: '0 10px',
|
||||
fontFamily: fontMono, fontSize: 12, outline: 'none', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
boxShadow: focus ? `0 0 0 3px ${t.accent}22` : 'none',
|
||||
}} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtCompact(n) {
|
||||
if (n < 1024) return n.toFixed(0);
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(0);
|
||||
return (n / 1024 / 1024).toFixed(1);
|
||||
}
|
||||
function fmtUnit(n) {
|
||||
if (n < 1024) return 'B/s';
|
||||
if (n < 1024 * 1024) return 'KB/s';
|
||||
return 'MB/s';
|
||||
}
|
||||
|
||||
window.HeroWindow = HeroWindow;
|
||||
@@ -0,0 +1,358 @@
|
||||
// drover-minimal.jsx — Variant 2: Minimal / Fluent-aligned.
|
||||
// Windows 11 Mica feel. Generous spacing. Subtle accents. Segoe UI Variable.
|
||||
// Soft cards on a tinted background, accent: a calm slate-blue.
|
||||
|
||||
const MinTheme = {
|
||||
d: {
|
||||
bg: '#1e1f23', mica: 'rgba(255,255,255,0.02)', chrome: 'rgba(255,255,255,0.03)',
|
||||
panel: 'rgba(255,255,255,0.04)', panel2: 'rgba(255,255,255,0.06)',
|
||||
border: 'rgba(255,255,255,0.08)', borderStrong: 'rgba(255,255,255,0.14)',
|
||||
text: '#f3f3f3', dim: '#b0b3ba', dimmer: '#7a7d85',
|
||||
accent: '#76b3ff', accentBg: 'rgba(118,179,255,0.16)',
|
||||
danger: '#ff7a7a', warn: '#ffb86b', pass: '#7ad29c', skip: '#9aa0aa',
|
||||
inputBg: 'rgba(255,255,255,0.04)', inputBorder: 'rgba(255,255,255,0.16)',
|
||||
primaryBg: '#76b3ff', primaryFg: '#0d1722',
|
||||
},
|
||||
l: {
|
||||
bg: '#f6f6f8', mica: 'rgba(255,255,255,0.5)', chrome: 'rgba(255,255,255,0.65)',
|
||||
panel: '#ffffff', panel2: '#fafafc',
|
||||
border: 'rgba(0,0,0,0.06)', borderStrong: 'rgba(0,0,0,0.14)',
|
||||
text: '#1c1d20', dim: '#4f5460', dimmer: '#8a8f97',
|
||||
accent: '#3a72c7', accentBg: 'rgba(58,114,199,0.10)',
|
||||
danger: '#c0463f', warn: '#9c6a14', pass: '#2c8a5a', skip: '#7c8088',
|
||||
inputBg: '#ffffff', inputBorder: 'rgba(0,0,0,0.18)',
|
||||
primaryBg: '#3a72c7', primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
function MinimalWindow({ mode = 'light', initial }) {
|
||||
const t = MinTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const isActive = D.phase === 'active';
|
||||
const fontUI = '"Segoe UI Variable","Segoe UI",system-ui,-apple-system,sans-serif';
|
||||
const fontMono = '"Cascadia Mono","Consolas",ui-monospace,monospace';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13.5, overflow: 'hidden',
|
||||
boxShadow: mode === 'dark' ? '0 0 0 1px rgba(255,255,255,0.06)' : '0 0 0 1px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
{/* title */}
|
||||
<MinTitle t={t}/>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding: '8px 16px 0' }}>
|
||||
{/* Form card */}
|
||||
<Card t={t} style={{ padding: 16 }}>
|
||||
<CardHeader t={t}>SOCKS5 Proxy</CardHeader>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<MinField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
|
||||
</MinField>
|
||||
<MinField t={t} label="Port" style={{ width: 96 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
|
||||
</MinField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<MinToggle t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('min-login')?.focus(),30); }}>
|
||||
Authentication
|
||||
</MinToggle>
|
||||
<div style={{ display:'flex', gap: 10, marginTop: 10, opacity: D.form.auth ? 1 : 0.45,
|
||||
pointerEvents: D.form.auth ? 'auto':'none' }}>
|
||||
<MinField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="min-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
|
||||
</MinField>
|
||||
<MinField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
|
||||
</MinField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<button onClick={D.runCheck} disabled={D.phase === 'checking' || isActive} style={{
|
||||
width:'100%', padding: '10px 14px', borderRadius: 6, border: 'none',
|
||||
background: (D.phase === 'checking' || isActive) ? t.panel2 : t.primaryBg,
|
||||
color: (D.phase === 'checking' || isActive) ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 13.5, cursor: D.phase==='checking'?'not-allowed':'pointer',
|
||||
fontFamily: fontUI,
|
||||
}}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
|
||||
{/* Status card */}
|
||||
<Card t={t} style={{ padding: 14 }}>
|
||||
<CardHeader t={t}>Status</CardHeader>
|
||||
<MinStatus t={t} D={D} palette={palette} fontMono={fontMono}/>
|
||||
</Card>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
|
||||
{/* Action card */}
|
||||
<Card t={t} style={{ padding: 14 }}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<MinStartBtn t={t} D={D} fontUI={fontUI}/>
|
||||
<MinStopBtn t={t} D={D} fontUI={fontUI}/>
|
||||
</div>
|
||||
{isActive && <MinLiveStats t={t} stats={D.stats} fontMono={fontMono}/>}
|
||||
</Card>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<MinLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinTitle({ t }) {
|
||||
const cell = { width: 46, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
|
||||
return (
|
||||
<div style={{
|
||||
height: 36, background: t.chrome, display:'flex', alignItems:'center',
|
||||
borderBottom: `1px solid ${t.border}`, userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, padding:'0 14px', flex:1 }}>
|
||||
<window.BrandMark size={16} color={t.accent}/>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell} title="Settings"><window.IconGear color={t.dim}/></div>
|
||||
<div style={cell} title="Minimize"><window.IconMin color={t.dim}/></div>
|
||||
<div style={cell} title="Close"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#c0463f'; e.currentTarget.style.color = 'white'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.dim; }}>
|
||||
<window.IconClose/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Card({ t, children, style }) {
|
||||
return <div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8, ...style,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
function CardHeader({ t, children }) {
|
||||
return <div style={{ fontSize: 12, fontWeight: 600, color: t.dim, marginBottom: 10, letterSpacing: 0.2 }}>{children}</div>;
|
||||
}
|
||||
function MinField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim }}>{label}</span>
|
||||
{children}
|
||||
</label>;
|
||||
}
|
||||
function minInput(t, fontUI, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.inputBorder}`, borderRadius: 5, padding: '8px 10px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
};
|
||||
}
|
||||
function MinToggle({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 10, cursor:'pointer', userSelect:'none' }}>
|
||||
<span style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, boxSizing:'border-box',
|
||||
background: checked ? t.accent : t.borderStrong, transition: 'background .14s',
|
||||
display:'flex', alignItems:'center',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 7, background: 'white',
|
||||
transform: checked ? 'translateX(16px)' : 'translateX(0)', transition: 'transform .14s',
|
||||
}}/>
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 13 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function MinStatus({ t, D, palette, fontMono }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, padding: '6px 0' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? <SummaryPill t={t} kind="ok">All checks passed. Ready to start.</SummaryPill>
|
||||
: <SummaryPill t={t} kind="warn">{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</SummaryPill>}
|
||||
</div>
|
||||
|
||||
<div style={{ display:'flex', flexDirection:'column', gap: 2 }}>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id} style={{ borderRadius: 4, padding: '4px 4px' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 26 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={14}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim }}>
|
||||
{r?.metric}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}} title="Подробнее">
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 24px', padding: 10, background: t.panel2, borderRadius: 6,
|
||||
borderLeft: `2px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`${test.label}: ${r.error}`)}
|
||||
style={{
|
||||
marginTop: 6, background:'transparent', border:`1px solid ${t.border}`,
|
||||
color: t.dim, borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor:'pointer',
|
||||
display:'inline-flex', gap:4, alignItems:'center', fontFamily: fontMono,
|
||||
}}>
|
||||
<window.IconCopy color={t.dim}/> copy error
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SummaryPill({ t, kind, children }) {
|
||||
const c = kind === 'ok' ? t.pass : t.warn;
|
||||
return <div style={{
|
||||
padding: '7px 11px', borderRadius: 6, fontSize: 12.5, fontWeight: 500,
|
||||
background: kind==='ok' ? 'rgba(122,210,156,0.12)' : 'rgba(255,184,107,0.12)',
|
||||
color: c, border: `1px solid ${c}40`, width: '100%',
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
function MinStartBtn({ t, D, fontUI }) {
|
||||
const phase = D.phase;
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 6, fontWeight: 600, fontSize: 13.5,
|
||||
background: warning ? 'rgba(255,184,107,0.14)' : 'rgba(122,210,156,0.14)',
|
||||
border: `1px solid ${c}55`, color: c,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 8, fontFamily: fontUI,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: c }}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button onClick={D.startProxy} disabled={!checkedOk} style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 6, border:'none',
|
||||
background: checkedOk ? t.primaryBg : t.panel2, color: checkedOk ? t.primaryFg : t.dimmer,
|
||||
fontWeight: 600, fontSize: 13.5, cursor: checkedOk ? 'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Start proxying</button>
|
||||
);
|
||||
}
|
||||
function MinStopBtn({ t, D, fontUI }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 6,
|
||||
background: 'transparent', color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${enabled ? t.borderStrong : t.border}`, fontWeight: 600, fontSize: 13.5,
|
||||
cursor: enabled ? 'pointer' : 'not-allowed', fontFamily: fontUI,
|
||||
}}>Stop</button>
|
||||
);
|
||||
}
|
||||
function MinLiveStats({ t, stats, fontMono }) {
|
||||
const Cell = ({ icon, val, lbl }) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}
|
||||
<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 10, color: t.dimmer, textTransform:'uppercase', letterSpacing:0.5 }}>{lbl}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, paddingTop: 10, borderTop: `1px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center', color: t.dim,
|
||||
}}>
|
||||
<Cell icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(stats.up)}/>
|
||||
<Cell icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(stats.down)}/>
|
||||
<Cell val={stats.tcp} lbl="tcp"/>
|
||||
<Cell val={stats.udp} lbl="udp"/>
|
||||
<Cell val={window.fmtUptime(stats.uptimeS)} lbl="up"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MinLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '9px 16px', display:'flex', alignItems:'center', gap:10,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ fontWeight: 600 }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding: '0 16px 8px' }}>
|
||||
{['Copy all','Clear','Open log file'].map((l,i) => (
|
||||
<button key={l} onClick={i===0 ? () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n')) : i===1 ? D.clearLogs : undefined}
|
||||
style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 4,
|
||||
padding: '4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY: 'auto', padding: '8px 16px',
|
||||
fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.MinimalWindow = MinimalWindow;
|
||||
@@ -0,0 +1,308 @@
|
||||
// drover-shared.jsx — shared hooks, state machine, types for all Drover-Go variants.
|
||||
// Each variant imports these via window globals and renders them in its own visual language.
|
||||
|
||||
// ─── Test catalog ──────────────────────────────────────────────────────────
|
||||
const ALL_TESTS = [
|
||||
{ id: 'tcp', label: 'TCP reachability', desc: 'TCP-соединение до прокси установлено' },
|
||||
{ id: 'greet', label: 'SOCKS5 greeting', desc: 'Прокси отвечает по протоколу SOCKS5' },
|
||||
{ id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
|
||||
{ id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
|
||||
{ id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' },
|
||||
{ id: 'stun', label: 'UDP round-trip via STUN', desc: 'UDP-пакеты ходят туда-обратно' },
|
||||
{ id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' },
|
||||
];
|
||||
|
||||
// Pre-baked scenarios so the prototype feels alive. Each entry per test:
|
||||
// { result: 'passed'|'failed'|'skipped', metric: '12 ms' | 'ok' | …, error?: 'short msg', hint?: 'what to try' }
|
||||
const SCENARIOS = {
|
||||
// Default happy path (no auth)
|
||||
happy: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// With auth
|
||||
happyAuth: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
auth: { result: 'passed', metric: 'user/pass · ok' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// UDP fails — common Discord scenario
|
||||
udpFail: {
|
||||
tcp: { result: 'passed', metric: '17 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'failed', metric: 'X\'07 cmd not supported',
|
||||
error: 'Прокси не поддерживает UDP ASSOCIATE.',
|
||||
hint: 'Голос и демонстрация экрана работать не будут. Текст и API — будут. Попробуйте другой SOCKS5-сервер с поддержкой UDP.' },
|
||||
stun: { result: 'skipped', metric: 'требует UDP ASSOCIATE' },
|
||||
api: { result: 'passed', metric: '204 OK · 92 ms' },
|
||||
},
|
||||
};
|
||||
|
||||
function getTests(authEnabled) {
|
||||
return ALL_TESTS.filter(t => !t.authOnly || authEnabled);
|
||||
}
|
||||
|
||||
// ─── Drover state hook ─────────────────────────────────────────────────────
|
||||
// Owns: form values, diagnostic phase, per-test results, drover-active state,
|
||||
// live stats counter, log buffer.
|
||||
// phase: 'idle' | 'checking' | 'checked' | 'active'
|
||||
function useDrover(initial = {}) {
|
||||
const [form, setForm] = React.useState({
|
||||
host: '95.165.72.59',
|
||||
port: '12334',
|
||||
auth: false,
|
||||
login: '',
|
||||
password: '',
|
||||
...initial,
|
||||
});
|
||||
const [phase, setPhase] = React.useState('idle');
|
||||
const [results, setResults] = React.useState({}); // testId -> {result, metric, error, hint, expanded}
|
||||
const [running, setRunning] = React.useState(null); // currently-running test id
|
||||
const [scenario, setScenario] = React.useState('happy'); // for tweaks
|
||||
const [stats, setStats] = React.useState({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
const [logs, setLogs] = React.useState(() => seedLogs());
|
||||
const [logsOpen, setLogsOpen] = React.useState(false);
|
||||
const tests = getTests(form.auth);
|
||||
const lastSummary = React.useMemo(() => {
|
||||
if (phase !== 'checked' && phase !== 'active') return null;
|
||||
const ids = tests.map(t => t.id);
|
||||
const failed = ids.filter(id => results[id]?.result === 'failed').length;
|
||||
return { total: ids.length, failed };
|
||||
}, [phase, results, tests]);
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────
|
||||
function update(patch) { setForm(f => ({ ...f, ...patch })); }
|
||||
|
||||
function pushLog(level, msg) {
|
||||
setLogs(l => [...l.slice(-499), { t: Date.now(), level, msg }]);
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
if (phase === 'checking') return;
|
||||
setPhase('checking');
|
||||
setResults({});
|
||||
setRunning(null);
|
||||
pushLog('INFO', `connect ${form.host}:${form.port}${form.auth ? ' (auth)' : ''}`);
|
||||
const list = getTests(form.auth);
|
||||
const sc = form.auth && scenario === 'happy' ? SCENARIOS.happyAuth : SCENARIOS[scenario];
|
||||
for (const t of list) {
|
||||
setRunning(t.id);
|
||||
// shorter when scenario is failing past skipped tests
|
||||
const r = sc[t.id];
|
||||
if (r?.result === 'skipped') {
|
||||
await sleep(120);
|
||||
} else {
|
||||
await sleep(380 + Math.random()*220);
|
||||
}
|
||||
setResults(prev => ({ ...prev, [t.id]: { ...r, expanded: r?.result === 'failed' } }));
|
||||
pushLog(r?.result === 'failed' ? 'ERROR' : r?.result === 'skipped' ? 'WARN' : 'INFO',
|
||||
`${t.label}: ${r?.result}${r?.metric ? ' · ' + r.metric : ''}`);
|
||||
}
|
||||
setRunning(null);
|
||||
setPhase('checked');
|
||||
}
|
||||
|
||||
function startProxy() {
|
||||
if (phase !== 'checked') return;
|
||||
if (lastSummary?.failed === tests.length) return;
|
||||
setPhase('active');
|
||||
pushLog('INFO', 'drover: bound 127.0.0.1:1080 · routing discord traffic');
|
||||
}
|
||||
|
||||
function stopProxy() {
|
||||
if (phase !== 'active') return;
|
||||
setPhase('checked');
|
||||
setStats({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
pushLog('INFO', 'drover: stopped');
|
||||
}
|
||||
|
||||
// Live stats while active
|
||||
React.useEffect(() => {
|
||||
if (phase !== 'active') return;
|
||||
const id = setInterval(() => {
|
||||
setStats(s => ({
|
||||
up: Math.max(0, s.up + (Math.random()*40000 - 18000)),
|
||||
down: Math.max(0, s.down + (Math.random()*120000 - 50000)),
|
||||
tcp: Math.max(2, Math.min(28, s.tcp + (Math.random() < 0.3 ? (Math.random()<.5?-1:1) : 0))),
|
||||
udp: Math.max(0, Math.min(8, s.udp + (Math.random() < 0.2 ? (Math.random()<.5?-1:1) : 0))),
|
||||
uptimeS: s.uptimeS + 1,
|
||||
}));
|
||||
}, 1000);
|
||||
// initial values
|
||||
setStats({ up: 42000, down: 180000, tcp: 6, udp: 2, uptimeS: 0 });
|
||||
return () => clearInterval(id);
|
||||
}, [phase]);
|
||||
|
||||
// Toggle a test's expanded explanation
|
||||
function toggleExpand(id) {
|
||||
setResults(r => ({ ...r, [id]: { ...r[id], expanded: !r[id]?.expanded } }));
|
||||
}
|
||||
|
||||
return {
|
||||
form, update,
|
||||
phase, setPhase,
|
||||
tests, results, running,
|
||||
scenario, setScenario,
|
||||
stats,
|
||||
logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]),
|
||||
lastSummary,
|
||||
runCheck, startProxy, stopProxy,
|
||||
toggleExpand,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function seedLogs() {
|
||||
const t = Date.now();
|
||||
return [
|
||||
{ t: t-9200, level: 'INFO', msg: 'drover-go v0.4.2 starting' },
|
||||
{ t: t-9100, level: 'INFO', msg: 'config: ~/.drover/config.toml' },
|
||||
{ t: t-9000, level: 'INFO', msg: 'no active session' },
|
||||
];
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n < 1024) return n.toFixed(0) + ' B/s';
|
||||
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB/s';
|
||||
return (n/1024/1024).toFixed(2) + ' MB/s';
|
||||
}
|
||||
function fmtUptime(s) {
|
||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${ss}s`;
|
||||
return `${ss}s`;
|
||||
}
|
||||
function fmtTime(t) {
|
||||
const d = new Date(t);
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0');
|
||||
}
|
||||
|
||||
// ─── Shared icons (small, original) ────────────────────────────────────────
|
||||
// Drover-Go mark: a downward chevron through a ring — "tunneled traffic".
|
||||
function BrandMark({ size = 16, color = 'currentColor', strokeWidth = 1.6 }) {
|
||||
const s = size;
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={strokeWidth}/>
|
||||
<path d="M7 9 L12 14 L17 9" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 14 L12 19" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconGear({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="2.2" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8L3.4 3.4"
|
||||
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconMin({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M3 8h10" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
function IconClose({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
function IconChevron({ size=12, color='currentColor', dir='down' }) {
|
||||
const r = { down: 0, up: 180, left: 90, right: -90 }[dir];
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" style={{ transform: `rotate(${r}deg)` }}>
|
||||
<path d="M3 4.5 L6 7.5 L9 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
function IconCopy({ size=12, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M2 8.5V2.5C2 1.95 2.45 1.5 3 1.5h6" stroke={color} strokeWidth="1.2"/>
|
||||
</svg>;
|
||||
}
|
||||
function IconArrowUp({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 8.5V1.5M5 1.5L2 4.5M5 1.5L8 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
function IconArrowDown({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 1.5V8.5M5 8.5L2 5.5M5 8.5L8 5.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// ─── Test row state icons (per visual variant supplies its own colors) ─────
|
||||
function StatusDot({ state, palette, size = 12 }) {
|
||||
// state: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
const c = palette[state] || palette.pending;
|
||||
if (state === 'running') {
|
||||
return (
|
||||
<span style={{ display:'inline-block', width:size, height:size, position:'relative' }}>
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" style={{ animation: 'drv-spin 0.8s linear infinite' }}>
|
||||
<circle cx="8" cy="8" r="6" stroke={c} strokeOpacity="0.25" strokeWidth="2" fill="none"/>
|
||||
<path d="M8 2 a6 6 0 0 1 6 6" stroke={c} strokeWidth="2" strokeLinecap="round" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === 'passed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5 8.2l2 2 4-4.4" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" stroke={c} strokeWidth="1.4" strokeDasharray="2 2"/>
|
||||
<path d="M5 8h6" stroke={c} strokeWidth="1.4" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
// pending
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="3" fill="none" stroke={c} strokeWidth="1.4"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// CSS for the spinner — injected once.
|
||||
if (typeof document !== 'undefined' && !document.getElementById('drv-shared-css')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'drv-shared-css';
|
||||
s.textContent = `
|
||||
@keyframes drv-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes drv-pulse { 0%,100% { opacity:1; transform:scale(1);} 50% { opacity:.55; transform:scale(0.7);} }
|
||||
@keyframes drv-blink { 0%,100% { opacity:1;} 50% { opacity:.35;} }
|
||||
@keyframes drv-fadein { from { opacity:0; transform:translateY(-2px);} to { opacity:1; transform:none;} }
|
||||
.drv-fadein { animation: drv-fadein .18s ease-out; }
|
||||
.drv-pulsedot { animation: drv-pulse 1.4s ease-in-out infinite; }
|
||||
.drv-shimmer::after {
|
||||
content:''; position:absolute; inset:0; background: linear-gradient(90deg,transparent,rgba(255,255,255,.25),transparent);
|
||||
transform:translateX(-100%); animation: drv-shim 1.6s linear infinite;
|
||||
}
|
||||
@keyframes drv-shim { to { transform: translateX(100%); } }
|
||||
/* Hide scrollbars for log panes inside artboards */
|
||||
.drv-log::-webkit-scrollbar { width:6px; }
|
||||
.drv-log::-webkit-scrollbar-thumb { background: rgba(127,127,127,.35); border-radius: 3px; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// Expose globals
|
||||
Object.assign(window, {
|
||||
useDrover, getTests, ALL_TESTS, SCENARIOS,
|
||||
fmtBytes, fmtUptime, fmtTime,
|
||||
BrandMark, StatusDot,
|
||||
IconGear, IconMin, IconClose, IconChevron, IconCopy, IconArrowUp, IconArrowDown,
|
||||
});
|
||||
@@ -0,0 +1,903 @@
|
||||
// Drover-Go · style sketches v2 — "feels like real software" set.
|
||||
// Each artboard explores a DIFFERENT software UI paradigm + element vocabulary,
|
||||
// not just a different palette. 480×640 fixed Win11 window unless noted.
|
||||
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
|
||||
const Win = ({ children, bg = '#fff', radius = 8, ring = 'rgba(0,0,0,.18)' }) => (
|
||||
<div style={{
|
||||
width: W, height: H, background: bg, borderRadius: radius,
|
||||
boxShadow: `0 0 0 1px ${ring}, 0 24px 60px rgba(0,0,0,.18)`,
|
||||
overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column'
|
||||
}}>{children}</div>
|
||||
);
|
||||
|
||||
const Mark = ({ size = 14, color = '#0c8c7a' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6.5" stroke={color} strokeWidth="1.5"/>
|
||||
<path d="M5.5 5.5 8.5 8 5.5 10.5" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 5.5 12 8 9 10.5" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity=".5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TitleButtons = ({ color = '#666' }) => (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4, color }}>
|
||||
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>⚙</span>
|
||||
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>—</span>
|
||||
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>×</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 1 · Fluent / Win11 native — the safe baseline
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchFluent = () => {
|
||||
const f = "'Segoe UI Variable','Segoe UI',system-ui,sans-serif";
|
||||
return (
|
||||
<Win bg="#f3f3f3" radius={10}>
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#1a1a1a' }}>
|
||||
<Mark color="#0067c0" />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 11 }}>0.4.2</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ padding: 14, fontFamily: f, fontSize: 13.5, flex: 1, display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden' }}>
|
||||
<div style={{ background: '#fff', borderRadius: 8, padding: 12, boxShadow: '0 0 0 1px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.04)' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 10 }}>SOCKS5 Proxy</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<FluentField flex={1} label="Host" value="95.165.72.59" />
|
||||
<FluentField width={88} label="Port" value="12334" />
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12.5 }}>
|
||||
<span style={{ width: 16, height: 16, borderRadius: 4, border: '1px solid #ccc', background: '#fff' }} />
|
||||
<span style={{ color: '#888' }}>Authentication</span>
|
||||
</label>
|
||||
<button style={{ marginTop: 12, width: '100%', height: 34, background: '#0067c0', color: '#fff', border: 'none', borderRadius: 4, fontFamily: f, fontWeight: 600, fontSize: 13, boxShadow: '0 1px 0 rgba(0,0,0,.1) inset, 0 1px 2px rgba(0,103,192,.3)' }}>Check connection</button>
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: 8, padding: 12, boxShadow: '0 0 0 1px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.04)', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 10 }}>Status</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#0067c0', fontSize: 12.5, marginBottom: 8 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #0067c0', borderRightColor: 'transparent', animation: 'spin 1s linear infinite' }} />
|
||||
Running diagnostics…
|
||||
</div>
|
||||
{[
|
||||
['✓', 'TCP reachability', '12 ms', '#107c10'],
|
||||
['✓', 'SOCKS5 greeting', 'ok', '#107c10'],
|
||||
['◐', 'TCP CONNECT to Discord', '…', '#0067c0'],
|
||||
['·', 'UDP ASSOCIATE', '', '#bbb'],
|
||||
['·', 'UDP round-trip via STUN', '', '#bbb'],
|
||||
['·', 'Discord API reachable', '', '#bbb'],
|
||||
].map(([ic, name, val, c], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '4px 0', fontSize: 12.5 }}>
|
||||
<span style={{ width: 18, color: c, fontWeight: 600 }}>{ic}</span>
|
||||
<span style={{ flex: 1 }}>{name}</span>
|
||||
<span style={{ color: '#888', fontFamily: "'Cascadia Mono', monospace", fontSize: 11.5 }}>{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={{ flex: 1, height: 36, background: '#0067c0', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, opacity: 0.4 }}>Start proxying</button>
|
||||
<button style={{ flex: 1, height: 36, background: '#fff', border: '1px solid #ddd', borderRadius: 4, color: '#aaa' }}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const FluentField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width, display: 'block' }}>
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 3 }}>{label}</div>
|
||||
<div style={{ height: 30, background: '#fafafa', borderRadius: 4, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13, color: '#1a1a1a', borderBottom: '1.5px solid #888', boxShadow: '0 0 0 1px rgba(0,0,0,.06)' }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 2 · Sidebar nav (VS Code / Postman / Tower) — left rail with sections
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchSidebar = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#1e1f22" radius={8} ring="rgba(255,255,255,.06)">
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #2a2c30', fontFamily: f, fontSize: 12, color: '#cbd1d9' }}>
|
||||
<Mark color="#4f8eff" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 500 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>— proxy: 95.165.72.59:12334</span>
|
||||
<TitleButtons color="#888" />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||
{/* sidebar */}
|
||||
<div style={{ width: 56, background: '#191a1d', borderRight: '1px solid #2a2c30', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0', gap: 4, fontFamily: f }}>
|
||||
{[['◉', 'Proxy', true], ['◇', 'Diagnose'], ['≋', 'Traffic'], ['≣', 'Logs'], ['⚙', 'Settings']].map(([ic, l, on], i) => (
|
||||
<div key={i} style={{ width: 40, height: 40, borderRadius: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: on ? '#fff' : '#888', background: on ? '#2c3140' : 'transparent', borderLeft: on ? '2px solid #4f8eff' : '2px solid transparent', position: 'relative', fontSize: 14 }}>
|
||||
<span>{ic}</span>
|
||||
<span style={{ fontSize: 8, marginTop: 1 }}>{l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, fontFamily: f, fontSize: 13, color: '#cbd1d9' }}>
|
||||
{/* breadcrumb / tabs */}
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'flex-end', padding: '0 0 0 0', borderBottom: '1px solid #2a2c30', background: '#191a1d' }}>
|
||||
<div style={{ padding: '6px 12px', background: '#1e1f22', borderRight: '1px solid #2a2c30', borderTop: '2px solid #4f8eff', fontSize: 11.5, color: '#fff' }}>● Proxy</div>
|
||||
<div style={{ padding: '6px 12px', borderRight: '1px solid #2a2c30', fontSize: 11.5, color: '#888' }}>diagnostic.run</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 14, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#888', textTransform: 'uppercase', marginBottom: 8 }}>Endpoint</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<div style={{ background: '#2c3140', border: '1px solid #3a4050', borderRight: 'none', padding: '0 10px', fontFamily: m, fontSize: 11, color: '#4f8eff', display: 'flex', alignItems: 'center', borderRadius: '4px 0 0 4px' }}>SOCKS5</div>
|
||||
<div style={{ flex: 1, background: '#15171b', border: '1px solid #3a4050', padding: '0 10px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 12, color: '#fff' }}>95.165.72.59</div>
|
||||
<div style={{ width: 70, background: '#15171b', border: '1px solid #3a4050', borderLeft: 'none', padding: '0 10px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 12, color: '#fff', borderRadius: '0 4px 4px 0' }}>:12334</div>
|
||||
<button style={{ padding: '0 16px', background: '#4f8eff', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 12.5 }}>Run ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#888', textTransform: 'uppercase', marginBottom: 8 }}>Diagnostic results</div>
|
||||
<div style={{ background: '#15171b', border: '1px solid #2a2c30', borderRadius: 5, fontFamily: m, fontSize: 11.5 }}>
|
||||
{[
|
||||
['✓', 'tcp_reachability', '12 ms', '#21d07a'],
|
||||
['✓', 'socks5_greeting', 'ok', '#21d07a'],
|
||||
['✓', 'socks5_authentication', 'ok', '#21d07a'],
|
||||
['✓', 'tcp_connect_discord', '38 ms', '#21d07a'],
|
||||
['✗', 'udp_associate', 'EPERM: blocked', '#ff5e5e'],
|
||||
['—', 'udp_stun_rtt', 'skipped', '#666'],
|
||||
['—', 'discord_api', 'skipped', '#666'],
|
||||
].map(([ic, n, v, c], i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '5px 10px', borderBottom: i < 6 ? '1px solid #1f2126' : 'none' }}>
|
||||
<span style={{ width: 16, color: c }}>{ic}</span>
|
||||
<span style={{ flex: 1, color: c === '#666' ? '#666' : '#cbd1d9' }}>{n}</span>
|
||||
<span style={{ color: c }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, padding: 8, background: '#3a1f1f', border: '1px solid #6e2a2a', borderRadius: 5, fontSize: 11.5, color: '#ff9e9e' }}>
|
||||
⚠ 1 of 7 checks failed. UDP voice/screenshare won't work.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* status bar */}
|
||||
<div style={{ height: 22, display: 'flex', alignItems: 'center', padding: '0 10px', background: '#15171b', borderTop: '1px solid #2a2c30', fontFamily: m, fontSize: 10.5, color: '#888' }}>
|
||||
<span style={{ color: '#ff9e9e' }}>● 6/7</span>
|
||||
<span style={{ marginLeft: 12 }}>idle</span>
|
||||
<span style={{ marginLeft: 'auto' }}>UTF-8 · LF · drover.toml</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 3 · Step wizard / Tab progression — DigiCert / Tunnelblick connect-flow
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchWizard = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
return (
|
||||
<Win bg="#fafafa" radius={8} ring="rgba(0,0,0,.12)">
|
||||
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 12, color: '#1a1a1a' }}>
|
||||
<Mark color="#1e6fd9" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go · Setup</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', fontFamily: f, color: '#1a1a1a' }}>
|
||||
{/* stepper */}
|
||||
<div style={{ display: 'flex', padding: '14px 18px', gap: 0, alignItems: 'center', borderBottom: '1px solid #ececec', background: '#fff' }}>
|
||||
{[['1', 'Configure', true], ['2', 'Verify', true], ['3', 'Connect', false]].map(([n, l, done], i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: '50%', background: i === 1 ? '#1e6fd9' : (done ? '#1e6fd9' : '#ddd'), color: '#fff', fontSize: 11, fontWeight: 600, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{done && i !== 1 ? '✓' : n}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: i === 1 ? 600 : 400, color: i === 1 ? '#1a1a1a' : '#888' }}>{l}</span>
|
||||
</div>
|
||||
{i < 2 && <div style={{ flex: 1, height: 1, background: i === 0 ? '#1e6fd9' : '#ddd', margin: '0 10px' }} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 14, overflow: 'hidden' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Verifying your proxy</h2>
|
||||
<div style={{ fontSize: 12.5, color: '#666', marginTop: 4 }}>We're running 7 checks against <span style={{ fontFamily: "'JetBrains Mono',monospace", color: '#1a1a1a' }}>95.165.72.59:12334</span> to make sure Discord will work end-to-end.</div>
|
||||
</div>
|
||||
{/* progress + ring */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, padding: 14, background: '#fff', border: '1px solid #ececec', borderRadius: 8 }}>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke="#eee" strokeWidth="6" />
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke="#1e6fd9" strokeWidth="6" strokeLinecap="round" strokeDasharray="163.4" strokeDashoffset="70" transform="rotate(-90 32 32)" />
|
||||
<text x="32" y="36" textAnchor="middle" fontSize="14" fontWeight="600" fill="#1a1a1a">4/7</text>
|
||||
</svg>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Testing UDP relay…</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>This usually takes 5–15 seconds.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12.5 }}>
|
||||
{[
|
||||
['✓', 'TCP reachability', '12 ms', '#21a655'],
|
||||
['✓', 'SOCKS5 greeting', 'ok', '#21a655'],
|
||||
['✓', 'SOCKS5 authentication', 'ok', '#21a655'],
|
||||
['✓', 'TCP CONNECT to Discord', '38 ms', '#21a655'],
|
||||
['◐', 'UDP ASSOCIATE', 'running…', '#1e6fd9'],
|
||||
['·', 'UDP round-trip via STUN', '', '#bbb'],
|
||||
['·', 'Discord API reachable', '', '#bbb'],
|
||||
].map(([ic, n, v, c], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '5px 10px', background: i === 4 ? '#eef4fc' : 'transparent', borderRadius: 4 }}>
|
||||
<span style={{ width: 18, color: c, fontWeight: 600 }}>{ic}</span>
|
||||
<span style={{ flex: 1, color: c === '#bbb' ? '#999' : '#1a1a1a' }}>{n}</span>
|
||||
<span style={{ color: c, fontFamily: "'JetBrains Mono',monospace", fontSize: 11.5 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: 50, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, padding: '0 18px', borderTop: '1px solid #ececec', background: '#fff' }}>
|
||||
<button style={{ height: 30, padding: '0 14px', background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12.5 }}>Back</button>
|
||||
<button style={{ height: 30, padding: '0 14px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontSize: 12.5, fontWeight: 600, opacity: 0.5 }}>Continue →</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 4 · Network monitor — gauges, sparklines, multi-pane (Wireshark / Activity)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchMonitor = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#fcfcfc" radius={6} ring="rgba(0,0,0,.12)">
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #e6e6e6', fontFamily: f, fontSize: 12, background: '#f5f5f5' }}>
|
||||
<Mark color="#0c8c7a" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, fontFamily: m, fontSize: 10, color: '#21a655' }}>● ACTIVE 4m 12s</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 12, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: f, fontSize: 12, color: '#222', overflow: 'hidden' }}>
|
||||
{/* compact endpoint */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px', background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#21a655' }} />
|
||||
<span style={{ fontFamily: m, fontSize: 11.5 }}>95.165.72.59:12334</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 11 }}>SOCKS5 · auth</span>
|
||||
<button style={{ background: 'transparent', border: '1px solid #ddd', borderRadius: 4, padding: '2px 8px', fontSize: 11 }}>Edit</button>
|
||||
</div>
|
||||
{/* meters */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 8 }}>
|
||||
<Gauge label="Upload" v="142" unit="KB/s" pct={28} c="#0c8c7a" />
|
||||
<Gauge label="Download" v="2.8" unit="MB/s" pct={84} c="#1e6fd9" />
|
||||
</div>
|
||||
{/* sparkline panel */}
|
||||
<div style={{ background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, padding: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 10.5, letterSpacing: '.06em', color: '#888', textTransform: 'uppercase' }}>Throughput · last 60s</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: m, fontSize: 11, color: '#222' }}>peak 3.4 MB/s</span>
|
||||
</div>
|
||||
<svg width="100%" height="60" viewBox="0 0 400 60" preserveAspectRatio="none">
|
||||
<path d="M0 50 L20 45 L40 30 L60 35 L80 18 L100 25 L120 12 L140 22 L160 8 L180 18 L200 28 L220 15 L240 30 L260 20 L280 38 L300 25 L320 32 L340 18 L360 24 L380 14 L400 20 L400 60 L0 60Z" fill="#1e6fd9" opacity=".15" />
|
||||
<path d="M0 50 L20 45 L40 30 L60 35 L80 18 L100 25 L120 12 L140 22 L160 8 L180 18 L200 28 L220 15 L240 30 L260 20 L280 38 L300 25 L320 32 L340 18 L360 24 L380 14 L400 20" fill="none" stroke="#1e6fd9" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* connections table */}
|
||||
<div style={{ flex: 1, background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', padding: '6px 10px', background: '#f5f5f5', borderBottom: '1px solid #e6e6e6', fontSize: 10.5, color: '#888', letterSpacing: '.06em', textTransform: 'uppercase' }}>
|
||||
<span style={{ width: 50 }}>Proto</span>
|
||||
<span style={{ flex: 1 }}>Endpoint</span>
|
||||
<span style={{ width: 80, textAlign: 'right' }}>Up</span>
|
||||
<span style={{ width: 80, textAlign: 'right' }}>Down</span>
|
||||
</div>
|
||||
{[
|
||||
['UDP', 'voice-eu-rtc-1.discord', '142 KB/s', '2.4 MB/s', '#1e6fd9'],
|
||||
['TCP', 'gateway.discord:443', '8 KB/s', '12 KB/s', '#0c8c7a'],
|
||||
['TCP', 'cdn.discordapp:443', '0', '380 KB/s', '#0c8c7a'],
|
||||
['TCP', 'media.discordapp:443', '0', '8 KB/s', '#0c8c7a'],
|
||||
].map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '5px 10px', fontFamily: m, fontSize: 11, borderBottom: i < 3 ? '1px solid #f0f0f0' : 'none' }}>
|
||||
<span style={{ width: 50, color: r[4], fontWeight: 600 }}>{r[0]}</span>
|
||||
<span style={{ flex: 1 }}>{r[1]}</span>
|
||||
<span style={{ width: 80, textAlign: 'right', color: '#666' }}>{r[2]}</span>
|
||||
<span style={{ width: 80, textAlign: 'right', color: '#666' }}>{r[3]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={{ flex: 1, height: 32, background: '#21a655', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 12.5 }}>● Active · 4m 12s</button>
|
||||
<button style={{ flex: 1, height: 32, background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12.5 }}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const Gauge = ({ label, v, unit, pct, c }) => (
|
||||
<div style={{ background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, padding: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: 10.5, letterSpacing: '.06em', color: '#888', textTransform: 'uppercase' }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 600, fontFamily: "'JetBrains Mono',monospace" }}>{v}</span>
|
||||
<span style={{ fontSize: 11, color: '#888' }}>{unit}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: '#f0f0f0', borderRadius: 2, marginTop: 6, overflow: 'hidden' }}>
|
||||
<div style={{ width: pct + '%', height: '100%', background: c }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 5 · Inspector / Properties — devtools-style key:value list
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchInspector = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#fff" radius={6} ring="rgba(0,0,0,.12)">
|
||||
<div style={{ height: 28, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 11.5 }}>
|
||||
<Mark color="#1a1a1a" size={12} />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 10.5 }}>0.4.2</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
{/* tabs */}
|
||||
<div style={{ height: 28, display: 'flex', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 11.5, background: '#fafafa' }}>
|
||||
{['Inspector', 'Network', 'Console', 'Settings'].map((t, i) => (
|
||||
<div key={t} style={{ padding: '0 14px', display: 'flex', alignItems: 'center', borderBottom: i === 0 ? '2px solid #1a1a1a' : '2px solid transparent', color: i === 0 ? '#1a1a1a' : '#888', fontWeight: i === 0 ? 600 : 400 }}>{t}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontFamily: m, fontSize: 11.5, color: '#1a1a1a', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Group title="proxy" expanded>
|
||||
<KV k="protocol" v="SOCKS5" t="enum" />
|
||||
<KV k="host" v={<input style={{ all: 'unset', width: '100%', fontFamily: m, fontSize: 11.5, color: '#1a1a1a' }} defaultValue="95.165.72.59" />} editable />
|
||||
<KV k="port" v="12334" t="int" editable />
|
||||
<KV k="auth.enabled" v="true" t="bool" />
|
||||
<KV k="auth.user" v="alice" t="str" editable />
|
||||
<KV k="auth.pass" v="••••••••" t="str" editable />
|
||||
</Group>
|
||||
<Group title="diagnostic.last_run" expanded>
|
||||
<KV k="started_at" v="14:32:01" t="time" />
|
||||
<KV k="duration" v="3.2 s" t="dur" />
|
||||
<KV k="passed" v="7" t="int" color="#21a655" />
|
||||
<KV k="failed" v="0" t="int" />
|
||||
<KV k="checks[0].name" v="tcp_reachability" />
|
||||
<KV k="checks[0].rtt" v="12 ms" />
|
||||
<KV k="checks[1].name" v="socks5_greeting" />
|
||||
<KV k="checks[2].name" v="socks5_authentication" />
|
||||
</Group>
|
||||
<Group title="state" expanded>
|
||||
<KV k="status" v="ready" t="enum" color="#21a655" />
|
||||
<KV k="active" v="false" t="bool" />
|
||||
</Group>
|
||||
</div>
|
||||
<div style={{ height: 32, display: 'flex', gap: 6, padding: '0 8px', alignItems: 'center', borderTop: '1px solid #ececec', background: '#fafafa' }}>
|
||||
<button style={{ height: 22, padding: '0 10px', background: '#fff', border: '1px solid #ccc', borderRadius: 3, fontFamily: f, fontSize: 11 }}>Run diagnostic</button>
|
||||
<button style={{ height: 22, padding: '0 10px', background: '#1a1a1a', color: '#fff', border: 'none', borderRadius: 3, fontFamily: f, fontSize: 11, fontWeight: 600 }}>▸ Start proxying</button>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: m, fontSize: 10.5, color: '#888' }}>14 props · saved</span>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const Group = ({ title, expanded, children }) => (
|
||||
<div>
|
||||
<div style={{ padding: '4px 10px', background: '#f0f0f0', fontSize: 10.5, fontWeight: 600, color: '#444', display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: 4, fontSize: 9 }}>{expanded ? '▼' : '▶'}</span>{title}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
const KV = ({ k, v, t, color, editable }) => (
|
||||
<div style={{ display: 'flex', padding: '3px 10px', borderBottom: '1px solid #f5f5f5', alignItems: 'center', fontSize: 11 }}>
|
||||
<span style={{ width: 150, color: '#888' }}>{k}</span>
|
||||
<span style={{ flex: 1, color: color || '#1a1a1a', background: editable ? '#f9f9f9' : 'transparent', borderRadius: 2, padding: '1px 4px' }}>{v}</span>
|
||||
{t && <span style={{ marginLeft: 8, fontSize: 9.5, color: '#bbb', textTransform: 'uppercase' }}>{t}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 6 · Command palette + form — Raycast / Linear / cmd+k feel
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchPalette = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#0f1015" radius={14} ring="rgba(255,255,255,.08)">
|
||||
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12, color: '#cbd1d9' }}>
|
||||
<Mark color="#a5b4ff" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 500 }}>Drover-Go</span>
|
||||
<TitleButtons color="#666" />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 14, fontFamily: f, color: '#e8e8ea', display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
|
||||
{/* command bar */}
|
||||
<div style={{ background: '#15171c', border: '1px solid #2a2d36', borderRadius: 9, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, boxShadow: '0 0 0 3px rgba(165,180,255,.08)' }}>
|
||||
<span style={{ color: '#a5b4ff', fontSize: 14 }}>⌘</span>
|
||||
<input style={{ all: 'unset', flex: 1, fontFamily: f, fontSize: 13.5, color: '#fff' }} placeholder="Search commands…" defaultValue="check con" />
|
||||
<span style={{ fontFamily: m, fontSize: 10, color: '#666', padding: '2px 6px', background: '#1a1c22', border: '1px solid #2a2d36', borderRadius: 4 }}>esc</span>
|
||||
</div>
|
||||
{/* suggestion */}
|
||||
<div style={{ background: '#15171c', borderRadius: 9, padding: 6 }}>
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#23264a', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: 5, background: '#a5b4ff', color: '#0f1015', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 12 }}>▸</span>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>Check connection</span>
|
||||
<span style={{ fontFamily: m, fontSize: 10.5, color: '#a5b4ff' }}>↵</span>
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 10, color: '#888' }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: 5, background: '#1a1c22', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 12 }}>●</span>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>Start proxying</span>
|
||||
<span style={{ fontFamily: m, fontSize: 10.5 }}>⇧↵</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Form below — proxy quick-edit */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 10, minHeight: 0 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#666', textTransform: 'uppercase' }}>Proxy</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<PalField flex={1} label="Host" value="95.165.72.59" />
|
||||
<PalField width={86} label="Port" value="12334" />
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#666', textTransform: 'uppercase', marginTop: 4 }}>Last run · 7/7 passed</div>
|
||||
{[
|
||||
['tcp_reachability', '12 ms'],
|
||||
['socks5_greeting', 'ok'],
|
||||
['udp_associate', 'relay 1.2.3.4'],
|
||||
['udp_stun_rtt', '24 ms'],
|
||||
['discord_api', '200'],
|
||||
].map(([n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '4px 10px', fontFamily: m, fontSize: 11.5, background: '#15171c', borderRadius: 5 }}>
|
||||
<span style={{ color: '#21d07a', marginRight: 8 }}>✓</span>
|
||||
<span style={{ flex: 1, color: '#cbd1d9' }}>{n}</span>
|
||||
<span style={{ color: '#888' }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0', fontFamily: m, fontSize: 10.5, color: '#666' }}>
|
||||
<span><kbd style={{ background: '#1a1c22', border: '1px solid #2a2d36', padding: '1px 5px', borderRadius: 3, color: '#cbd1d9' }}>↵</kbd> run</span>
|
||||
<span><kbd style={{ background: '#1a1c22', border: '1px solid #2a2d36', padding: '1px 5px', borderRadius: 3, color: '#cbd1d9' }}>⇧↵</kbd> start</span>
|
||||
<span style={{ marginLeft: 'auto' }}>idle · ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const PalField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 10.5, color: '#888', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ height: 32, background: '#15171c', border: '1px solid #2a2d36', borderRadius: 7, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13, color: '#fff', fontFamily: "'JetBrains Mono',monospace" }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 7 · Big toggle / Hero — TunnelBear / NordVPN big-button connect
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchHero = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#0e1426" radius={14} ring="rgba(255,255,255,.06)">
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#cbd1d9' }}>
|
||||
<Mark color="#5dd4b3" />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#5dd4b3' }}>● connected</span>
|
||||
<TitleButtons color="#666" />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '20px 18px', fontFamily: f, color: '#fff', display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', overflow: 'hidden' }}>
|
||||
{/* Big circular button */}
|
||||
<div style={{ width: 180, height: 180, borderRadius: '50%', background: 'radial-gradient(circle at 35% 30%, #5dd4b3 0%, #2da085 60%, #1a6e5b 100%)', boxShadow: '0 0 60px rgba(93,212,179,.4), 0 0 0 6px rgba(93,212,179,.1), 0 0 0 12px rgba(93,212,179,.05)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', position: 'relative', marginTop: 12 }}>
|
||||
<div style={{ position: 'absolute', inset: -20, borderRadius: '50%', border: '1px solid rgba(93,212,179,.2)', animation: 'pulse 2s infinite' }} />
|
||||
<span style={{ fontSize: 36 }}>⏻</span>
|
||||
<span style={{ fontSize: 11, letterSpacing: '.2em', marginTop: 4, opacity: .9 }}>ACTIVE</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600 }}>Discord is protected</div>
|
||||
<div style={{ fontSize: 12.5, color: '#7a8499', marginTop: 4, fontFamily: m }}>via 95.165.72.59 · 4m 12s</div>
|
||||
</div>
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 0, width: '100%', background: '#15192a', borderRadius: 10, padding: 12, justifyContent: 'space-around' }}>
|
||||
{[
|
||||
['↑', '142', 'KB/s'],
|
||||
['↓', '2.8', 'MB/s'],
|
||||
['◇', '14', 'tcp'],
|
||||
['◈', '3', 'udp'],
|
||||
].map(([k, v, u], i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#5dd4b3' }}>{k}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: m, marginTop: 2 }}>{v}</div>
|
||||
<div style={{ fontSize: 9.5, color: '#7a8499', letterSpacing: '.06em', textTransform: 'uppercase' }}>{u}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button style={{ width: '100%', height: 38, background: 'transparent', color: '#cbd1d9', border: '1px solid #2a3042', borderRadius: 9, fontWeight: 500, fontSize: 13 }}>Disconnect</button>
|
||||
<div style={{ width: '100%', fontSize: 11, color: '#5a6178', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 10 }}>▸</span><span>Logs (3 new)</span>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`@keyframes pulse{0%,100%{transform:scale(1);opacity:.4}50%{transform:scale(1.08);opacity:0}}`}</style>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 8 · Toolbar + ribbon — old-school Office / Audacity feel
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchToolbar = () => {
|
||||
const f = "'Segoe UI', system-ui, sans-serif";
|
||||
const m = "'Consolas', 'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#ebebeb" radius={4} ring="rgba(0,0,0,.18)">
|
||||
<div style={{ height: 26, display: 'flex', alignItems: 'center', padding: '0 8px', background: 'linear-gradient(180deg,#f5f5f5,#dcdcdc)', borderBottom: '1px solid #b8b8b8', fontFamily: f, fontSize: 11 }}>
|
||||
<Mark color="#1a1a1a" size={12} />
|
||||
<span style={{ marginLeft: 6, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>— [Connected]</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
{/* menu bar */}
|
||||
<div style={{ height: 22, display: 'flex', padding: '0 6px', background: '#ebebeb', borderBottom: '1px solid #c8c8c8', fontFamily: f, fontSize: 11, alignItems: 'center', gap: 2 }}>
|
||||
{['File', 'Edit', 'Run', 'View', 'Tools', 'Help'].map(m => (
|
||||
<span key={m} style={{ padding: '2px 8px', borderRadius: 2 }}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* ribbon */}
|
||||
<div style={{ display: 'flex', padding: '6px 8px', background: 'linear-gradient(180deg,#f5f5f5,#e8e8e8)', borderBottom: '1px solid #c8c8c8', gap: 4 }}>
|
||||
<Tool icon="▶" label="Run" primary />
|
||||
<Tool icon="●" label="Start" />
|
||||
<Tool icon="■" label="Stop" disabled />
|
||||
<div style={{ width: 1, background: '#c8c8c8', margin: '4px 6px' }} />
|
||||
<Tool icon="⚙" label="Config" />
|
||||
<Tool icon="⎘" label="Copy" />
|
||||
<div style={{ width: 1, background: '#c8c8c8', margin: '4px 6px' }} />
|
||||
<Tool icon="≣" label="Logs" />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 10, display: 'flex', flexDirection: 'column', gap: 8, fontFamily: f, fontSize: 11.5, color: '#1a1a1a', overflow: 'hidden' }}>
|
||||
<Fieldset label="SOCKS5 Proxy">
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ width: 40 }}>Host:</span>
|
||||
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 11.5 }}>95.165.72.59</div>
|
||||
<span>Port:</span>
|
||||
<div style={{ width: 70, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 11.5 }}>12334</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 }}>
|
||||
<span style={{ width: 14, height: 14, border: '1px solid #888', background: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10 }}>✓</span>
|
||||
<span>Authentication</span>
|
||||
<span style={{ marginLeft: 12 }}>User:</span>
|
||||
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center' }}>alice</div>
|
||||
</div>
|
||||
</Fieldset>
|
||||
<Fieldset label="Diagnostics" flex>
|
||||
<div style={{ background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', flex: 1, fontFamily: m, fontSize: 11, overflow: 'auto' }}>
|
||||
{[
|
||||
['✓', 'TCP reachability', 'PASS · 12 ms'],
|
||||
['✓', 'SOCKS5 greeting', 'PASS · ok'],
|
||||
['✓', 'SOCKS5 authentication', 'PASS · ok'],
|
||||
['✓', 'TCP CONNECT to Discord', 'PASS · 38 ms'],
|
||||
['✓', 'UDP ASSOCIATE', 'PASS · relay 1.2.3.4'],
|
||||
['✓', 'UDP round-trip via STUN', 'PASS · 24 ms'],
|
||||
['✓', 'Discord API reachable', 'PASS · 200'],
|
||||
].map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '3px 8px', borderBottom: i < 6 ? '1px dotted #ddd' : 'none' }}>
|
||||
<span style={{ width: 14, color: '#107c10' }}>{r[0]}</span>
|
||||
<span style={{ flex: 1 }}>{r[1]}</span>
|
||||
<span style={{ color: '#666' }}>{r[2]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Fieldset>
|
||||
</div>
|
||||
{/* status bar */}
|
||||
<div style={{ height: 22, display: 'flex', padding: '0 8px', background: '#dcdcdc', borderTop: '1px solid #b8b8b8', alignItems: 'center', fontSize: 10.5, fontFamily: f, gap: 12 }}>
|
||||
<span>Ready</span>
|
||||
<span>● 7/7</span>
|
||||
<span>↑ 142 KB/s ↓ 2.8 MB/s</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: m }}>uptime 4m 12s</span>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const Tool = ({ icon, label, primary, disabled }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '2px 8px', borderRadius: 2, background: primary ? 'linear-gradient(180deg,#fff,#e0e0e0)' : 'transparent', border: primary ? '1px solid #888' : '1px solid transparent', minWidth: 44, opacity: disabled ? 0.4 : 1 }}>
|
||||
<span style={{ fontSize: 16, color: primary ? '#107c10' : '#1a1a1a' }}>{icon}</span>
|
||||
<span style={{ fontSize: 10, marginTop: 1 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
const Fieldset = ({ label, children, flex }) => (
|
||||
<div style={{ border: '1px solid #b8b8b8', borderRadius: 2, padding: '10px 8px 8px', position: 'relative', background: '#f5f5f5', flex: flex ? 1 : undefined, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ position: 'absolute', top: -8, left: 8, background: '#ebebeb', padding: '0 4px', fontSize: 10.5, fontWeight: 600 }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 9 · Modern dev tool — Studio (Linear-ish, restrained dark)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchStudio = () => {
|
||||
const f = "'Inter', sans-serif";
|
||||
return (
|
||||
<Win bg="#0e0f12" radius={12} ring="rgba(255,255,255,.08)">
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#e8e8ea' }}>
|
||||
<Mark color="#7c8aff" />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#5a5d6a', fontSize: 11 }}>0.4.2</span>
|
||||
<TitleButtons color="#777a86" />
|
||||
</div>
|
||||
<div style={{ padding: 14, fontFamily: f, color: '#e8e8ea', flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.04em', color: '#a1a3ad', marginBottom: 8, textTransform: 'uppercase' }}>SOCKS5 Proxy</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<StField flex={1} label="Host" value="95.165.72.59" />
|
||||
<StField width={86} label="Port" value="12334" />
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12.5, color: '#a1a3ad' }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: 4, background: '#7c8aff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 10 }}>✓</span>
|
||||
Authentication
|
||||
</label>
|
||||
<button style={{ marginTop: 12, width: '100%', height: 34, background: '#7c8aff', color: '#0a0a0c', border: 'none', borderRadius: 7, fontWeight: 600, fontSize: 13 }}>Check connection</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.04em', color: '#a1a3ad', marginBottom: 8, display: 'flex', textTransform: 'uppercase' }}>
|
||||
Status <span style={{ marginLeft: 'auto', fontSize: 11, color: '#21d07a', fontWeight: 500, textTransform: 'none' }}>● All systems</span>
|
||||
</div>
|
||||
<div style={{ background: '#15171c', border: '1px solid #1f2228', borderRadius: 9, padding: 4, flex: 1 }}>
|
||||
{[
|
||||
['TCP reachability', '12 ms'],
|
||||
['SOCKS5 greeting', 'ok'],
|
||||
['SOCKS5 authentication', 'ok'],
|
||||
['TCP CONNECT to Discord', '38 ms'],
|
||||
['UDP ASSOCIATE', 'relay 1.2.3.4'],
|
||||
['UDP round-trip via STUN', '24 ms'],
|
||||
['Discord API reachable', '200'],
|
||||
].map(([n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '6px 8px', borderRadius: 6, fontSize: 12.5 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#21d07a22', border: '1px solid #21d07a', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#21d07a', fontSize: 9, marginRight: 10 }}>✓</span>
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
<span style={{ color: '#777a86', fontFamily: "'Geist Mono',monospace", fontSize: 11.5 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={{ flex: 1, height: 36, background: '#7c8aff', color: '#0a0a0c', border: 'none', borderRadius: 7, fontWeight: 600 }}>Start proxying</button>
|
||||
<button style={{ flex: 1, height: 36, background: '#1a1c22', color: '#e8e8ea', border: '1px solid #2a2d36', borderRadius: 7 }}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const StField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 11, color: '#777a86', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ height: 32, background: '#15171c', border: '1px solid #1f2228', borderRadius: 7, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13 }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 10 · Pipeline / DAG — visualize the test chain as a flow
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchPipeline = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
const Node = ({ x, y, label, status, sub }) => {
|
||||
const c = status === 'pass' ? '#21a655' : status === 'fail' ? '#e85a4f' : status === 'run' ? '#1e6fd9' : '#aaa';
|
||||
const bg = status === 'pass' ? '#e8f7ee' : status === 'fail' ? '#fdebe9' : status === 'run' ? '#eaf1fb' : '#f4f4f4';
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<rect width="120" height="40" rx="6" fill={bg} stroke={c} strokeWidth="1.5" />
|
||||
<circle cx="14" cy="20" r="5" fill={c} />
|
||||
<text x="26" y="17" fontSize="11" fontWeight="600" fill="#1a1a1a" fontFamily={f}>{label}</text>
|
||||
<text x="26" y="30" fontSize="9.5" fill="#666" fontFamily={m}>{sub}</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
const Arrow = ({ from, to, dashed }) => (
|
||||
<line x1={from[0]} y1={from[1]} x2={to[0]} y2={to[1]} stroke="#bbb" strokeWidth="1.5" strokeDasharray={dashed ? '3 3' : ''} markerEnd="url(#ar)" />
|
||||
);
|
||||
return (
|
||||
<Win bg="#fafafa" radius={6}>
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 12 }}>
|
||||
<Mark color="#1a1a1a" size={12} />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 11 }}>· pipeline view</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* compact endpoint */}
|
||||
<div style={{ padding: 10, display: 'flex', gap: 6, alignItems: 'center', borderBottom: '1px solid #ececec', background: '#fff' }}>
|
||||
<span style={{ fontSize: 11, color: '#888', fontFamily: m }}>SOCKS5</span>
|
||||
<input style={{ flex: 1, height: 26, padding: '0 8px', border: '1px solid #d0d0d0', borderRadius: 4, fontFamily: m, fontSize: 11.5 }} defaultValue="95.165.72.59" />
|
||||
<input style={{ width: 70, height: 26, padding: '0 8px', border: '1px solid #d0d0d0', borderRadius: 4, fontFamily: m, fontSize: 11.5 }} defaultValue="12334" />
|
||||
<button style={{ height: 26, padding: '0 12px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11.5 }}>Run ▸</button>
|
||||
</div>
|
||||
{/* DAG */}
|
||||
<div style={{ flex: 1, padding: 12, background: 'radial-gradient(circle at 1px 1px, #ddd 1px, transparent 0) 0 0/16px 16px', overflow: 'auto' }}>
|
||||
<svg width="456" height="380" viewBox="0 0 456 380">
|
||||
<defs>
|
||||
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0 L10 5 L0 10 z" fill="#bbb"/></marker>
|
||||
</defs>
|
||||
<Node x={20} y={20} label="TCP reach" sub="12 ms" status="pass" />
|
||||
<Arrow from={[140, 40]} to={[160, 40]} />
|
||||
<Node x={160} y={20} label="SOCKS5 greet" sub="ok" status="pass" />
|
||||
<Arrow from={[280, 40]} to={[300, 40]} />
|
||||
<Node x={300} y={20} label="SOCKS5 auth" sub="ok" status="pass" />
|
||||
<Arrow from={[360, 60]} to={[360, 90]} />
|
||||
<Node x={300} y={90} label="CONNECT discord" sub="38 ms" status="pass" />
|
||||
<Arrow from={[300, 110]} to={[180, 160]} />
|
||||
<Arrow from={[300, 110]} to={[180, 220]} />
|
||||
<Node x={60} y={140} label="UDP ASSOCIATE" sub="running…" status="run" />
|
||||
<Arrow from={[180, 160]} to={[60, 240]} dashed />
|
||||
<Node x={60} y={220} label="UDP STUN RTT" sub="pending" status="pending" />
|
||||
<Node x={60} y={300} label="Discord API" sub="pending" status="pending" />
|
||||
<Arrow from={[180, 240]} to={[60, 320]} dashed />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ padding: 10, borderTop: '1px solid #ececec', background: '#fff', display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#1e6fd9', fontFamily: m }}>● 4/7 running</span>
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||
<button style={{ height: 28, padding: '0 14px', background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12 }}>Cancel</button>
|
||||
<button style={{ height: 28, padding: '0 14px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontSize: 12, fontWeight: 600, opacity: 0.5 }}>Start proxying</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 11 · Bauhaus — geometric blocks, primary colors, asymmetric grid
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchBauhaus = () => {
|
||||
const f = "'Space Grotesk', sans-serif";
|
||||
return (
|
||||
<Win bg="#f4ede0" radius={0} ring="#000">
|
||||
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '3px solid #000', fontFamily: f, fontSize: 13, fontWeight: 700 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#e63946' }} />
|
||||
<span style={{ width: 14, height: 14, background: '#1d3557', marginLeft: 6 }} />
|
||||
<span style={{ width: 0, height: 0, borderLeft: '7px solid transparent', borderRight: '7px solid transparent', borderBottom: '14px solid #f1c40f', marginLeft: 6 }} />
|
||||
<span style={{ marginLeft: 12 }}>DROVER-GO</span>
|
||||
<span style={{ marginLeft: 'auto', fontWeight: 400, fontSize: 11 }}>v0.4.2 · ⚙ · — · ×</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 12, fontFamily: f, color: '#000', display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: 'auto 1fr auto', gap: 8 }}>
|
||||
<div style={{ gridColumn: '1 / -1', background: '#1d3557', color: '#fff', padding: 14, border: '3px solid #000' }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: '.15em', opacity: .7 }}>FORM 01</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, marginTop: 2 }}>SOCKS5 PROXY</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<BaField flex={1} label="HOST" value="95.165.72.59" dark />
|
||||
<BaField width={84} label="PORT" value="12334" dark />
|
||||
</div>
|
||||
</div>
|
||||
<button style={{ background: '#e63946', color: '#fff', border: '3px solid #000', padding: '10px', fontFamily: f, fontSize: 13, fontWeight: 700, letterSpacing: '.06em' }}>CHECK →</button>
|
||||
<button style={{ background: '#f1c40f', color: '#000', border: '3px solid #000', padding: '10px', fontFamily: f, fontSize: 13, fontWeight: 700, letterSpacing: '.06em' }}>● START</button>
|
||||
<div style={{ gridColumn: '1 / -1', border: '3px solid #000', background: '#fff', padding: 14, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: '.15em', opacity: .6 }}>STATUS · 7/7</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 10 }}>ALL CHECKS PASSED</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 14px' }}>
|
||||
{[
|
||||
['TCP reach', '12ms'],
|
||||
['SOCKS5 greet', 'ok'],
|
||||
['SOCKS5 auth', 'ok'],
|
||||
['CONNECT', '38ms'],
|
||||
['UDP assoc', 'ok'],
|
||||
['UDP STUN', '24ms'],
|
||||
['API', '200'],
|
||||
].map(([n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', fontSize: 12, borderBottom: '1px solid #000' }}>
|
||||
<span style={{ width: 8, height: 8, background: '#e63946', marginRight: 8 }} />
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
<span style={{ fontWeight: 700 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const BaField = ({ label, value, flex, width, dark }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 9.5, marginBottom: 3, opacity: .7, letterSpacing: '.1em' }}>{label}</div>
|
||||
<div style={{ height: 30, background: dark ? '#fff' : '#000', color: dark ? '#000' : '#fff', border: '2px solid #000', padding: '0 8px', display: 'flex', alignItems: 'center', fontSize: 13, fontWeight: 600 }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 12 · Brutalist — hard borders, mono, acid lime
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchBrutalist = () => {
|
||||
const m = "'Geist Mono', 'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#f3f1e8" radius={0} ring="#000">
|
||||
<div style={{ height: 38, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '2px solid #000', fontFamily: m, fontSize: 12, color: '#000', background: '#e8e4d2' }}>
|
||||
<span style={{ fontWeight: 700 }}>▶ DROVER-GO</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 10 }}>v0.4.2</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 14, fontWeight: 700 }}>
|
||||
<span>[⚙]</span><span>[—]</span><span>[X]</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 14, fontFamily: m, fontSize: 12, color: '#000', flex: 1, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ border: '2px solid #000', background: '#fff', padding: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 8 }}>// SOCKS5 PROXY</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<BrField flex={1} label="HOST" value="95.165.72.59" />
|
||||
<BrField width={92} label="PORT" value="12334" />
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: 12 }}>[X] AUTHENTICATION</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<BrField flex={1} label="LOGIN" value="alice" />
|
||||
<BrField flex={1} label="PASSWORD" value="********" />
|
||||
</div>
|
||||
<button style={{ marginTop: 12, width: '100%', height: 36, background: '#c5f74d', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700, fontSize: 13, cursor: 'pointer' }}>► CHECK CONNECTION</button>
|
||||
</div>
|
||||
<div style={{ border: '2px solid #000', background: '#fff', padding: 10, flex: 1 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 8 }}>// STATUS · 7/7 OK</div>
|
||||
{[
|
||||
['[OK]', 'TCP_REACHABILITY', '12ms'],
|
||||
['[OK]', 'SOCKS5_GREETING', 'ok'],
|
||||
['[OK]', 'SOCKS5_AUTH', 'ok'],
|
||||
['[OK]', 'CONNECT_DISCORD', '38ms'],
|
||||
['[OK]', 'UDP_ASSOCIATE', 'relay 1.2.3.4'],
|
||||
['[OK]', 'UDP_STUN_RTT', '24ms'],
|
||||
['[OK]', 'DISCORD_API', '200'],
|
||||
].map(([s, n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '3px 0', borderBottom: i < 6 ? '1px dashed #999' : 'none' }}>
|
||||
<span style={{ width: 38, fontWeight: 700 }}>{s}</span>
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
<span>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button style={{ flex: 1, height: 38, background: '#c5f74d', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700 }}>► START</button>
|
||||
<button style={{ flex: 1, height: 38, background: '#fff', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700 }}>■ STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const BrField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 9.5, marginBottom: 3 }}>{label}</div>
|
||||
<div style={{ height: 30, border: '2px solid #000', padding: '0 8px', display: 'flex', alignItems: 'center', fontSize: 12.5, background: '#fff' }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// SketchesApp
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const SKETCHES = [
|
||||
{ id: 'fluent', label: '01 · Fluent / Win11 native', el: <SketchFluent /> },
|
||||
{ id: 'sidebar', label: '02 · IDE sidebar (VS Code-ish)', el: <SketchSidebar /> },
|
||||
{ id: 'wizard', label: '03 · Wizard / stepper', el: <SketchWizard /> },
|
||||
{ id: 'monitor', label: '04 · Network monitor (gauges)', el: <SketchMonitor /> },
|
||||
{ id: 'inspector', label: '05 · Inspector / properties', el: <SketchInspector /> },
|
||||
{ id: 'palette', label: '06 · Command palette (cmd+k)', el: <SketchPalette /> },
|
||||
{ id: 'hero', label: '07 · Big toggle (VPN-style)', el: <SketchHero /> },
|
||||
{ id: 'toolbar', label: '08 · Toolbar / ribbon (classic)', el: <SketchToolbar /> },
|
||||
{ id: 'studio', label: '09 · Studio (Linear-ish)', el: <SketchStudio /> },
|
||||
{ id: 'pipeline', label: '10 · Pipeline / DAG view', el: <SketchPipeline /> },
|
||||
{ id: 'bauhaus', label: '11 · Bauhaus blocks', el: <SketchBauhaus /> },
|
||||
{ id: 'brutalist', label: '12 · Brutalist (mono+lime)', el: <SketchBrutalist /> },
|
||||
];
|
||||
|
||||
const SketchesApp = () => (
|
||||
<DesignCanvas>
|
||||
<DCSection id="about" title="Drover-Go" subtitle="12 directions — every one looks/works like real software, not a marketing site. Different UI paradigms (sidebar, wizard, palette, ribbon, DAG, gauges…), not just different palettes.">
|
||||
<div data-dc-static style={{ width: 720, padding: '20px 24px', background: '#fff', borderRadius: 10, boxShadow: '0 0 0 1px rgba(0,0,0,.06)', fontSize: 13, lineHeight: 1.55, color: '#3a3530' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 14 }}>What changed</div>
|
||||
<p style={{ margin: '0 0 8px' }}>Removed the "marketing" stylings (ticket, synthwave, soft pastel, editorial serif). Replaced with software-native paradigms — each card explores a <em>different way to organize the same UI</em>, not just a different paint job:</p>
|
||||
<ul style={{ margin: '0 0 8px', paddingLeft: 18 }}>
|
||||
<li><b>Layout patterns:</b> sidebar IDE · wizard/stepper · pipeline/DAG · ribbon · palette+form · big-toggle hero</li>
|
||||
<li><b>Element vocab:</b> gauges + sparklines · key/value inspector · fieldsets · tabs+breadcrumbs · status bar · toolbar buttons</li>
|
||||
<li><b>Aesthetic:</b> Fluent (Win11) · classic Office · Linear/Studio · brutalist · Bauhaus blocks</li>
|
||||
</ul>
|
||||
<p style={{ margin: 0, color: '#7a7065' }}>Tell me which paradigm/aesthetic combo to take forward.</p>
|
||||
</div>
|
||||
</DCSection>
|
||||
<DCSection id="sketches" title="12 directions" subtitle="Drag to reorder. Click any artboard's expand icon to focus.">
|
||||
{SKETCHES.map(s => (
|
||||
<DCArtboard key={s.id} id={s.id} label={s.label} width={W} height={H}>
|
||||
{s.el}
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
|
||||
// Expose individual sketches so other files (e.g. shortlist) can render them.
|
||||
Object.assign(window, {
|
||||
SketchFluent, SketchSidebar, SketchWizard, SketchMonitor,
|
||||
SketchInspector, SketchPalette, SketchHero, SketchToolbar,
|
||||
SketchStudio, SketchPipeline, SketchBauhaus, SketchBrutalist,
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// drover-studio.jsx — Studio: modern, calm, Linear-ish. Subtle blue accent.
|
||||
|
||||
const STD = {
|
||||
bg: '#0e0f12', chrome: '#0a0b0d', panel: '#15171b', panel2: '#1a1c21',
|
||||
border: '#22252b', borderSoft: '#1a1c21',
|
||||
text: '#e7e9ee', dim: '#9095a0', dimmer: '#5e6470',
|
||||
accent: '#6c8aff', accentSoft: 'rgba(108,138,255,0.14)',
|
||||
danger: '#ff7d70', danSoft: 'rgba(255,125,112,0.12)',
|
||||
warn: '#f5b13d', warnSoft: 'rgba(245,177,61,0.12)',
|
||||
pass: '#6cd391', passSoft: 'rgba(108,211,145,0.12)',
|
||||
skip: '#7e848e',
|
||||
inputBg: '#0e0f12',
|
||||
primaryFg: '#0c1226',
|
||||
};
|
||||
|
||||
function StudioWindow({ initial }) {
|
||||
const t = STD;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,-apple-system,sans-serif';
|
||||
const fontMono = '"JetBrains Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontUI, fontSize: 13, overflow:'hidden', border:'1px solid #000',
|
||||
}}>
|
||||
<StdTitle t={t}/>
|
||||
<div style={{ flex: 1, overflow:'auto', padding:'14px 16px 4px' }}>
|
||||
|
||||
<StdSection t={t} title="SOCKS5 Proxy">
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<StdField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<StdInput t={t} fontUI={fontUI} value={D.form.host}
|
||||
onChange={v => D.update({ host: v })} onSubmit={D.runCheck}
|
||||
placeholder="95.165.72.59 или example.com"/>
|
||||
</StdField>
|
||||
<StdField t={t} label="Port" style={{ width: 96 }}>
|
||||
<StdInput t={t} fontUI={fontUI} value={D.form.port}
|
||||
onChange={v => D.update({ port: v.replace(/\D/g,'') })} onSubmit={D.runCheck}
|
||||
placeholder="12334" inputMode="numeric"/>
|
||||
</StdField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<StdCheck t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('std-login')?.focus(),30); }}>
|
||||
Authentication
|
||||
</StdCheck>
|
||||
<div style={{
|
||||
display:'flex', gap: 10, marginTop: 10,
|
||||
opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth?'auto':'none',
|
||||
}}>
|
||||
<StdField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<StdInput id="std-login" t={t} fontUI={fontUI} value={D.form.login}
|
||||
onChange={v => D.update({ login: v })} onSubmit={D.runCheck} placeholder="user" disabled={!D.form.auth}/>
|
||||
</StdField>
|
||||
<StdField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<StdInput t={t} fontUI={fontUI} value={D.form.password} type="password"
|
||||
onChange={v => D.update({ password: v })} onSubmit={D.runCheck} placeholder="••••••"
|
||||
disabled={!D.form.auth}/>
|
||||
</StdField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
|
||||
width:'100%', padding:'9px 14px', borderRadius: 6, border:'none',
|
||||
background: (D.phase==='checking'||isActive) ? t.panel2 : t.accent,
|
||||
color: (D.phase==='checking'||isActive) ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 13, cursor: D.phase==='checking'?'not-allowed':'pointer',
|
||||
fontFamily: fontUI,
|
||||
}}>{D.phase==='checking' ? 'Checking…' : 'Check connection'}</button>
|
||||
</StdSection>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<StdSection t={t} title="Status" right={
|
||||
D.phase==='checking' ? `${Object.keys(D.results).length}/${D.tests.length}` :
|
||||
D.lastSummary ? (D.lastSummary.failed === 0 ? 'all passed' : `${D.lastSummary.failed} failed`) : null
|
||||
}>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, color: t.dim, padding:'2px 0' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 3, background: t.dimmer }}/>
|
||||
Ready to check
|
||||
</div>
|
||||
: <StdStatus t={t} D={D} palette={palette} fontMono={fontMono}/>}
|
||||
</StdSection>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<StdSection t={t}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<StdStartBtn t={t} D={D} fontUI={fontUI}/>
|
||||
<StdStopBtn t={t} D={D} fontUI={fontUI}/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 12, paddingTop: 12, borderTop:`1px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', color: t.dim,
|
||||
}}>
|
||||
<StdStat icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(D.stats.up)} fontMono={fontMono} t={t}/>
|
||||
<StdStat icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(D.stats.down)} fontMono={fontMono} t={t}/>
|
||||
<StdStat val={D.stats.tcp} lbl="TCP" fontMono={fontMono} t={t}/>
|
||||
<StdStat val={D.stats.udp} lbl="UDP" fontMono={fontMono} t={t}/>
|
||||
<StdStat val={window.fmtUptime(D.stats.uptimeS)} lbl="UP" fontMono={fontMono} t={t}/>
|
||||
</div>
|
||||
)}
|
||||
</StdSection>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
<StdLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StdTitle({ t }) {
|
||||
const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
|
||||
return (
|
||||
<div style={{ height: 36, background: t.chrome, borderBottom:`1px solid ${t.border}`, display:'flex', alignItems:'center', userSelect:'none' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, padding:'0 14px', flex:1 }}>
|
||||
<window.BrandMark size={15} color={t.accent}/>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell}><window.IconGear color={t.dim}/></div>
|
||||
<div style={cell}><window.IconMin color={t.dim}/></div>
|
||||
<div style={cell}><window.IconClose color={t.dim}/></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StdSection({ t, title, right, children }) {
|
||||
return (
|
||||
<section>
|
||||
{(title || right) && (
|
||||
<div style={{ display:'flex', alignItems:'baseline', marginBottom: 8 }}>
|
||||
{title && <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, letterSpacing: 0.5 }}>{title}</div>}
|
||||
{right && <div style={{ marginLeft:'auto', fontSize: 11, color: t.dimmer, fontFamily:'"JetBrains Mono",monospace' }}>{right}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ background: t.panel, border:`1px solid ${t.border}`, borderRadius: 8, padding: 14 }}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function StdField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function StdInput({ t, fontUI, value, onChange, type, placeholder, onSubmit, disabled, id, inputMode }) {
|
||||
return <input id={id} value={value} type={type||'text'} disabled={disabled}
|
||||
inputMode={inputMode}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border:`1px solid ${t.border}`, borderRadius: 5, padding:'8px 10px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
}}/>;
|
||||
}
|
||||
function StdCheck({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 8, cursor:'pointer' }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4, border:`1px solid ${checked?t.accent:t.border}`,
|
||||
background: checked ? t.accent : 'transparent',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', transition:'all .12s',
|
||||
}}>
|
||||
{checked && <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5l2 2 4-4.4" stroke={t.primaryFg} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{display:'none'}}/>
|
||||
<span style={{ fontSize: 13 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function StdStatus({ t, D, palette, fontMono }) {
|
||||
const allOk = D.lastSummary?.failed === 0;
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding:'7px 11px', marginBottom: 10, borderRadius: 6, fontSize: 12.5, fontWeight: 500,
|
||||
background: allOk ? t.passSoft : t.warnSoft,
|
||||
border:`1px solid ${allOk ? t.pass : t.warn}40`,
|
||||
color: allOk ? t.pass : t.warn,
|
||||
}}>
|
||||
{allOk ? 'All checks passed. Ready to start.'
|
||||
: `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
|
||||
</div>}
|
||||
<div>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 26 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={13}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{
|
||||
marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim,
|
||||
}}>{r?.metric}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 23px', padding: 10, borderRadius: 6,
|
||||
background: t.danSoft, border: `1px solid ${t.danger}30`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function StdStartBtn({ t, D, fontUI }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 6, fontWeight: 600, fontSize: 13,
|
||||
background: warning ? t.warnSoft : t.passSoft, color: c, border:`1px solid ${c}55`,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 7, height: 7, borderRadius: 4, background: c }}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 6, border:'none',
|
||||
background: ok ? t.accent : t.panel2, color: ok ? t.primaryFg : t.dimmer,
|
||||
fontWeight: 600, fontSize: 13, cursor: ok?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Start proxying</button>;
|
||||
}
|
||||
function StdStopBtn({ t, D, fontUI }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return <button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 6, background:'transparent',
|
||||
color: enabled ? t.text : t.dimmer, border:`1px solid ${t.border}`,
|
||||
fontWeight: 600, fontSize: 13, cursor: enabled?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Stop</button>;
|
||||
}
|
||||
function StdStat({ icon, val, lbl, fontMono, t }) {
|
||||
return <div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 10, color: t.dimmer, textTransform:'uppercase', letterSpacing: 0.5 }}>{lbl}</span>}
|
||||
</div>;
|
||||
}
|
||||
function StdLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'9px 16px', display:'flex', alignItems:'center', gap: 9,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
|
||||
<span style={{ fontWeight: 600 }}>Logs</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding:'0 16px 8px' }}>
|
||||
{[['Copy all', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['Clear', D.clearLogs], ['Open log file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 4,
|
||||
padding:'4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY:'auto', padding:'8px 16px',
|
||||
fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.StudioWindow = StudioWindow;
|
||||
@@ -0,0 +1,455 @@
|
||||
// drover-wizard-live.jsx — Wizard / stepper variant.
|
||||
// 3 steps: Configure → Verify → Connect. State derives from D.phase.
|
||||
// Step 1 = idle (form). Step 2 = checking/checked (diagnostics). Step 3 = active (running proxy).
|
||||
|
||||
const WIZARD_THEME = {
|
||||
l: {
|
||||
bg: '#fafafa',
|
||||
chrome: '#ffffff',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f6f7f9',
|
||||
border: '#ececec',
|
||||
borderHard:'#dcdcdc',
|
||||
text: '#1a1a1a',
|
||||
dim: '#666666',
|
||||
dimmer: '#9aa0a6',
|
||||
accent: '#1e6fd9',
|
||||
accentSoft:'#eef4fc',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#21a655',
|
||||
skip: '#888888',
|
||||
},
|
||||
d: {
|
||||
bg: '#0f1115',
|
||||
chrome: '#171a20',
|
||||
panel: '#1a1d23',
|
||||
panelAlt: '#13151a',
|
||||
border: '#2a2d33',
|
||||
borderHard:'#3a3d45',
|
||||
text: '#e6e8ec',
|
||||
dim: '#9aa0a6',
|
||||
dimmer: '#5d6168',
|
||||
accent: '#5b9bf0',
|
||||
accentSoft:'#1a253a',
|
||||
danger: '#e57373',
|
||||
warn: '#d9a155',
|
||||
pass: '#5cba8b',
|
||||
skip: '#7c8088',
|
||||
},
|
||||
};
|
||||
|
||||
function WizardWindow({ mode = 'light', initial }) {
|
||||
const themeKey = mode === 'dark' ? 'd' : 'l';
|
||||
const t = WIZARD_THEME[themeKey];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = "'Inter','Segoe UI',system-ui,sans-serif";
|
||||
const fontMono = "'JetBrains Mono','SF Mono',ui-monospace,Consolas,monospace";
|
||||
|
||||
// Determine current step from phase
|
||||
const phase = D.phase;
|
||||
const step = phase === 'idle' ? 1
|
||||
: (phase === 'checking' || phase === 'checked') ? 2
|
||||
: 3; // active
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text,
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.45,
|
||||
borderRadius: 0,
|
||||
}}>
|
||||
<WizardTitleBar t={t} />
|
||||
|
||||
{/* Stepper */}
|
||||
<div style={{
|
||||
display: 'flex', padding: '14px 18px', alignItems: 'center',
|
||||
borderBottom: `1px solid ${t.border}`, background: t.chrome, gap: 0,
|
||||
}}>
|
||||
{[[1, 'Configure'], [2, 'Verify'], [3, 'Connect']].map(([n, label], i) => {
|
||||
const done = step > n;
|
||||
const current = step === n;
|
||||
const dim = step < n;
|
||||
return (
|
||||
<React.Fragment key={n}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
background: dim ? t.panelAlt : t.accent,
|
||||
border: dim ? `1px solid ${t.borderHard}` : 'none',
|
||||
color: dim ? t.dimmer : '#fff',
|
||||
fontSize: 11, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background .2s',
|
||||
}}>{done ? '✓' : n}</span>
|
||||
<span style={{ fontSize: 12.5, fontWeight: current ? 600 : 400, color: dim ? t.dimmer : t.text }}>{label}</span>
|
||||
</div>
|
||||
{i < 2 && <div style={{
|
||||
flex: 1, height: 1, background: step > n ? t.accent : t.borderHard,
|
||||
margin: '0 10px', transition: 'background .2s',
|
||||
}} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{step === 1 && <WizConfigure t={t} D={D} fontMono={fontMono} />}
|
||||
{step === 2 && <WizVerify t={t} D={D} fontMono={fontMono} palette={palette} themeKey={themeKey} />}
|
||||
{step === 3 && <WizConnect t={t} D={D} fontMono={fontMono} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
height: 54, display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
|
||||
gap: 8, padding: '0 18px', borderTop: `1px solid ${t.border}`, background: t.chrome,
|
||||
}}>
|
||||
<WizardSecondaryBtn t={t} onClick={() => {
|
||||
if (step === 2) D.stopProxy?.(), D.setPhase('idle');
|
||||
if (step === 3) D.stopProxy();
|
||||
}} disabled={step === 1}>
|
||||
{step === 3 ? 'Disconnect' : 'Back'}
|
||||
</WizardSecondaryBtn>
|
||||
<WizardPrimaryBtn t={t}
|
||||
onClick={() => {
|
||||
if (step === 1) D.runCheck();
|
||||
else if (step === 2) D.startProxy();
|
||||
}}
|
||||
disabled={
|
||||
(step === 1 && (D.phase === 'checking')) ||
|
||||
(step === 2 && (D.phase === 'checking' || D.lastSummary?.failed === D.tests.length)) ||
|
||||
(step === 3)
|
||||
}>
|
||||
{step === 1 && 'Verify →'}
|
||||
{step === 2 && (D.phase === 'checking' ? 'Verifying…' : 'Connect →')}
|
||||
{step === 3 && 'Connected'}
|
||||
</WizardPrimaryBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardTitleBar({ t }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, display: 'flex', alignItems: 'center', padding: '0 12px',
|
||||
borderBottom: `1px solid ${t.border}`, fontSize: 12, color: t.text, background: t.chrome,
|
||||
}}>
|
||||
<window.BrandMark size={14} color={t.accent} />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go · Setup</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex' }}>
|
||||
<WizTitleBtn t={t}><window.IconGear color={t.dim} /></WizTitleBtn>
|
||||
<WizTitleBtn t={t}><window.IconMin color={t.dim} /></WizTitleBtn>
|
||||
<WizTitleBtn t={t} hoverBg="#c0463f" hoverFg="#fff"><window.IconClose color={t.dim} /></WizTitleBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function WizTitleBtn({ children, t, hoverBg, hoverFg }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{ width: 38, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
background: hover ? (hoverBg || 'rgba(127,127,127,.1)') : 'transparent',
|
||||
color: hover && hoverFg ? hoverFg : 'inherit',
|
||||
borderRadius: 4,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 1: Configure ───────────────────────────────────────────────────
|
||||
function WizConfigure({ t, D, fontMono }) {
|
||||
const inputStyle = (disabled) => ({
|
||||
height: 36, background: t.panel, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.borderHard}`, borderRadius: 6, padding: '0 12px',
|
||||
fontFamily: fontMono, fontSize: 13, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
});
|
||||
const onFocus = (e) => { e.target.style.borderColor = t.accent; e.target.style.boxShadow = `0 0 0 3px ${t.accentSoft}`; };
|
||||
const onBlur = (e) => { e.target.style.borderColor = t.borderHard; e.target.style.boxShadow = 'none'; };
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Configure your proxy</h2>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
|
||||
Введите адрес SOCKS5-сервера. На следующем шаге мы проверим, что Discord будет работать через него.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
padding: 16, display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Host</span>
|
||||
<input value={D.form.host}
|
||||
onChange={e => D.update({ host: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
style={inputStyle(false)} />
|
||||
</label>
|
||||
<label style={{ width: 100, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Port</span>
|
||||
<input value={D.form.port}
|
||||
onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
style={inputStyle(false)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 9, cursor: 'pointer', userSelect: 'none', fontSize: 13 }}>
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 4,
|
||||
border: `1.5px solid ${D.form.auth ? t.accent : t.borderHard}`,
|
||||
background: D.form.auth ? t.accent : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{D.form.auth && <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={D.form.auth}
|
||||
onChange={e => { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('wiz-login')?.focus(), 30); }}
|
||||
style={{ display: 'none' }} />
|
||||
<span>Authentication</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
|
||||
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Login</span>
|
||||
<input id="wiz-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="user" style={inputStyle(!D.form.auth)} />
|
||||
</label>
|
||||
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Password</span>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="••••••" style={inputStyle(!D.form.auth)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: t.dim, marginTop: 'auto', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: '50%', border: `1px solid ${t.borderHard}`,
|
||||
fontSize: 9, color: t.dim, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>i</span>
|
||||
Нажмите <span style={{ color: t.text, fontWeight: 600 }}>Verify →</span>, чтобы продолжить.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 2: Verify ───────────────────────────────────────────────────
|
||||
function WizVerify({ t, D, fontMono, palette, themeKey }) {
|
||||
const phase = D.phase;
|
||||
const total = D.tests.length;
|
||||
const completed = Object.keys(D.results).length;
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
const ringFrac = phase === 'checked' ? 1 : completed / total;
|
||||
const ringDash = 163.4;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
{phase === 'checking' ? 'Verifying your proxy' : (failed === 0 ? 'All checks passed' : 'Some checks failed')}
|
||||
</h2>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
|
||||
{phase === 'checking'
|
||||
? <>Запускаем 7 проверок против <span style={{ fontFamily: fontMono, color: t.text }}>{D.form.host}:{D.form.port}</span></>
|
||||
: (failed === 0
|
||||
? <>Прокси работает. Discord — голос, чат и демонстрация — будет работать через него.</>
|
||||
: <>{failed} из {total} проверок не прошли. Часть функций работать не будет.</>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* progress + ring */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 16, padding: 14,
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
}}>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke={t.border} strokeWidth="6" />
|
||||
<circle cx="32" cy="32" r="26" fill="none"
|
||||
stroke={failed > 0 ? t.danger : phase === 'checked' ? t.pass : t.accent}
|
||||
strokeWidth="6" strokeLinecap="round"
|
||||
strokeDasharray={ringDash}
|
||||
strokeDashoffset={ringDash * (1 - ringFrac)}
|
||||
transform="rotate(-90 32 32)"
|
||||
style={{ transition: 'stroke-dashoffset .35s, stroke .2s' }} />
|
||||
<text x="32" y="36" textAnchor="middle" fontSize="13" fontWeight="600" fill={t.text}>
|
||||
{phase === 'checked' ? `${total - failed}/${total}` : `${completed}/${total}`}
|
||||
</text>
|
||||
</svg>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
|
||||
{phase === 'checking' ? <>Testing <span style={{ fontFamily: fontMono }}>{D.tests.find(x => x.id === D.running)?.label || '…'}</span></> : (failed === 0 ? 'Готово к подключению' : 'Завершено с ошибками')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: t.dim, marginTop: 2 }}>
|
||||
{phase === 'checking' ? 'This usually takes 5–15 seconds.' : 'См. отчёт ниже.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* test list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 12.5 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 9, padding: '5px 10px',
|
||||
background: D.running === test.id ? t.accentSoft : 'transparent',
|
||||
borderRadius: 4, minHeight: 26,
|
||||
}}>
|
||||
<window.StatusDot state={state} palette={palette} size={12} />
|
||||
<span style={{ color: state === 'pending' ? t.dimmer : t.text }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? 'running…' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)}
|
||||
style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 0 4px 22px', padding: '8px 10px', borderRadius: 6,
|
||||
background: themeKey === 'l' ? '#fdf2f2' : '#3a1f1f',
|
||||
border: `1px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
|
||||
style={{ marginTop: 6, padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: t.panel, color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 4, cursor: 'pointer' }}>copy error</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3: Connect (active) ───────────────────────────────────────────
|
||||
function WizConnect({ t, D, fontMono }) {
|
||||
const stats = D.stats;
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="drv-pulsedot" style={{
|
||||
width: 10, height: 10, borderRadius: 5, background: failed > 0 ? t.warn : t.pass,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
{failed > 0 ? 'Connected · UDP fallback' : 'Connected'}
|
||||
</h2>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
|
||||
Discord routes through <span style={{ fontFamily: fontMono, color: t.text }}>{D.form.host}:{D.form.port}</span>
|
||||
{' · '}<span style={{ fontFamily: fontMono }}>{window.fmtUptime(stats.uptimeS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* stats grid */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8,
|
||||
}}>
|
||||
<WizStat t={t} fontMono={fontMono} label="Upload" value={window.fmtBytes(stats.up)} accent={t.accent} />
|
||||
<WizStat t={t} fontMono={fontMono} label="Download" value={window.fmtBytes(stats.down)} accent={t.pass} />
|
||||
<WizStat t={t} fontMono={fontMono} label="TCP" value={stats.tcp} accent={t.accent} />
|
||||
<WizStat t={t} fontMono={fontMono} label="UDP" value={stats.udp} accent={failed > 0 ? t.warn : t.pass} />
|
||||
</div>
|
||||
|
||||
{/* logs panel inline */}
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
padding: 12, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 600, letterSpacing: 0.2 }}>RECENT LOG</span>
|
||||
<button onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}
|
||||
style={{ marginLeft: 'auto', padding: '2px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: 'transparent', color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 4, cursor: 'pointer' }}>copy</button>
|
||||
<button onClick={D.clearLogs}
|
||||
style={{ marginLeft: 6, padding: '2px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: 'transparent', color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 4, cursor: 'pointer' }}>clear</button>
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
flex: 1, overflowY: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
lineHeight: 1.55, color: t.dim, minHeight: 0,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.slice(-30).map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WizStat({ t, fontMono, label, value, accent }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
}}>
|
||||
<div style={{ fontSize: 10.5, color: t.dim, letterSpacing: 0.6, textTransform: 'uppercase', fontWeight: 600 }}>{label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: fontMono, marginTop: 3, color: accent }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardPrimaryBtn({ t, onClick, disabled, children }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
height: 32, padding: '0 18px', borderRadius: 6, border: 'none',
|
||||
background: disabled ? t.panelAlt : (hover ? '#1a5fbf' : t.accent),
|
||||
color: disabled ? t.dimmer : '#fff',
|
||||
fontWeight: 600, fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
boxShadow: disabled ? 'none' : 'inset 0 -1px 0 rgba(0,0,0,.18)',
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
function WizardSecondaryBtn({ t, onClick, disabled, children }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
height: 32, padding: '0 14px', borderRadius: 6,
|
||||
background: hover && !disabled ? t.panelAlt : t.panel,
|
||||
color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.borderHard}`,
|
||||
fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
window.WizardWindow = WizardWindow;
|
||||
@@ -0,0 +1,322 @@
|
||||
// drover-workshop.jsx — Workshop: industrial slate + amber accent. Subtle texture.
|
||||
|
||||
const WSH = {
|
||||
bg: '#181a1d', chrome: '#121417', panel: '#1f2226', panel2: '#23272c',
|
||||
border: '#2c3036', borderSoft: '#222529',
|
||||
text: '#e5e3df', dim: '#9a958c', dimmer: '#5e5b56',
|
||||
accent: '#e89a3c', accentSoft: 'rgba(232,154,60,0.15)',
|
||||
danger: '#d56654', warn: '#d4a248', pass: '#7fb37b', skip: '#7c7872',
|
||||
inputBg: '#141619',
|
||||
primaryFg: '#1a120a',
|
||||
};
|
||||
|
||||
function WorkshopWindow({ initial }) {
|
||||
const t = WSH;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = '"IBM Plex Sans","Inter",system-ui,sans-serif';
|
||||
const fontMono = '"IBM Plex Mono","JetBrains Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontUI, fontSize: 13, overflow:'hidden',
|
||||
border:'1px solid #000', position:'relative',
|
||||
}}>
|
||||
{/* subtle grain */}
|
||||
<div style={{
|
||||
position:'absolute', inset: 0, pointerEvents:'none', opacity: 0.5,
|
||||
backgroundImage: `radial-gradient(rgba(255,255,255,0.014) 1px, transparent 1px)`,
|
||||
backgroundSize: '3px 3px',
|
||||
}}/>
|
||||
|
||||
{/* title — workshop label tag */}
|
||||
<div style={{
|
||||
height: 36, background: t.chrome, borderBottom:`1px solid ${t.border}`,
|
||||
display:'flex', alignItems:'center', userSelect:'none', position:'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 4, alignSelf:'stretch', background: t.accent, marginRight: 12,
|
||||
}}/>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, flex: 1 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, letterSpacing: 1.5, textTransform:'uppercase' }}>Drover-Go</span>
|
||||
<span style={{
|
||||
fontSize: 10, color: t.dim, fontFamily: fontMono, padding:'1px 5px',
|
||||
border:`1px solid ${t.border}`, borderRadius: 1,
|
||||
}}>v0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
{[<window.IconGear color={t.dim}/>, <window.IconMin color={t.dim}/>, <window.IconClose color={t.dim}/>].map((ic,i) => (
|
||||
<div key={i} style={{
|
||||
width: 40, height: 36, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
|
||||
borderLeft:`1px solid ${t.borderSoft}`,
|
||||
}}>{ic}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding:'14px 16px 4px', position:'relative', zIndex: 1 }}>
|
||||
{/* form */}
|
||||
<WshHead t={t}>SOCKS5 PROXY</WshHead>
|
||||
<div style={{ background: t.panel, padding: 13, borderLeft:`3px solid ${t.accent}`, borderRadius: 1 }}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<WshField t={t} label="HOST" style={{ flex: 1 }}>
|
||||
<WshInput t={t} value={D.form.host} onChange={v => D.update({ host: v })}
|
||||
placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
<WshField t={t} label="PORT" style={{ width: 96 }}>
|
||||
<WshInput t={t} value={D.form.port} onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
</div>
|
||||
<div style={{ height: 11 }}/>
|
||||
<WshSwitch t={t} checked={D.form.auth}
|
||||
onChange={v => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('wsh-login')?.focus(),30); }}>
|
||||
AUTHENTICATION
|
||||
</WshSwitch>
|
||||
<div style={{
|
||||
display:'flex', gap: 10, marginTop: 10,
|
||||
opacity: D.form.auth?1:0.4, pointerEvents: D.form.auth?'auto':'none',
|
||||
}}>
|
||||
<WshField t={t} label="LOGIN" style={{ flex: 1 }}>
|
||||
<WshInput id="wsh-login" t={t} value={D.form.login} disabled={!D.form.auth}
|
||||
onChange={v => D.update({ login: v })} placeholder="user" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
<WshField t={t} label="PASSWORD" style={{ flex: 1 }}>
|
||||
<WshInput t={t} value={D.form.password} type="password" disabled={!D.form.auth}
|
||||
onChange={v => D.update({ password: v })} placeholder="••••••" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
|
||||
width:'100%', padding:'9px 14px', borderRadius: 1, border:'none',
|
||||
background: (D.phase==='checking'||isActive) ? t.panel2 : t.accent,
|
||||
color: (D.phase==='checking'||isActive) ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
|
||||
cursor: D.phase==='checking'?'not-allowed':'pointer', fontFamily: fontUI,
|
||||
}}>{D.phase==='checking' ? 'Checking…' : 'Check connection'}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<WshHead t={t} right={
|
||||
D.phase==='checking' ? `${Object.keys(D.results).length}/${D.tests.length}` :
|
||||
D.lastSummary ? (D.lastSummary.failed === 0 ? 'all passed' : `${D.lastSummary.failed} failed`) : null
|
||||
}>STATUS</WshHead>
|
||||
<div style={{ background: t.panel, padding: 13, borderRadius: 1 }}>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, color: t.dim }}>
|
||||
<span style={{ width: 7, height: 7, background: t.dimmer }}/>
|
||||
Ready to check
|
||||
</div>
|
||||
: <WshStatus t={t} D={D} palette={palette} fontMono={fontMono}/>}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<div style={{ background: t.panel, padding: 13, borderRadius: 1 }}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<WshStartBtn t={t} D={D} fontUI={fontUI}/>
|
||||
<WshStopBtn t={t} D={D} fontUI={fontUI}/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 12, paddingTop: 12, borderTop:`1px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', color: t.dim,
|
||||
}}>
|
||||
<Stat val={window.fmtBytes(D.stats.up)} lbl="↑" t={t} fontMono={fontMono} hl={t.pass}/>
|
||||
<Stat val={window.fmtBytes(D.stats.down)} lbl="↓" t={t} fontMono={fontMono} hl={t.accent}/>
|
||||
<Stat val={D.stats.tcp} lbl="tcp" t={t} fontMono={fontMono}/>
|
||||
<Stat val={D.stats.udp} lbl="udp" t={t} fontMono={fontMono}/>
|
||||
<Stat val={window.fmtUptime(D.stats.uptimeS)} lbl="up" t={t} fontMono={fontMono}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
<WshLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WshHead({ t, children, right }) {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'baseline', marginBottom: 8 }}>
|
||||
<div style={{
|
||||
fontSize: 10.5, fontWeight: 700, letterSpacing: 2, color: t.dim,
|
||||
display:'flex', alignItems:'center', gap: 8,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 2, background: t.accent }}/>
|
||||
{children}
|
||||
</div>
|
||||
{right && <div style={{ marginLeft:'auto', fontSize: 10.5, color: t.dimmer, fontFamily:'"IBM Plex Mono",monospace', letterSpacing: 0.5 }}>{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function WshField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 10, color: t.dim, letterSpacing: 1.5, fontWeight: 600 }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function WshInput({ t, value, onChange, type, placeholder, onSubmit, disabled, id, fontUI, inputMode }) {
|
||||
return <input id={id} value={value} type={type||'text'} disabled={disabled} inputMode={inputMode}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border:`1px solid ${t.border}`, borderRadius: 1, padding:'8px 10px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
}}/>;
|
||||
}
|
||||
function WshSwitch({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 9, cursor:'pointer' }}>
|
||||
<span style={{
|
||||
width: 32, height: 18, padding: 2, boxSizing:'border-box',
|
||||
background: checked ? t.accent : t.border,
|
||||
display:'flex', alignItems:'center', transition:'background .14s', borderRadius: 1,
|
||||
}}>
|
||||
<span style={{
|
||||
width: 12, height: 12, background: checked ? '#1a120a' : '#9a958c',
|
||||
transform: checked ? 'translateX(14px)' : 'translateX(0)', transition:'transform .14s',
|
||||
}}/>
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 1.2 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function WshStatus({ t, D, palette, fontMono }) {
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding:'8px 11px', marginBottom: 10, fontSize: 12, fontWeight: 600, letterSpacing: 0.5,
|
||||
background: D.lastSummary?.failed === 0 ? `${t.pass}1f` : t.accentSoft,
|
||||
borderLeft: `3px solid ${D.lastSummary?.failed === 0 ? t.pass : t.accent}`,
|
||||
color: D.lastSummary?.failed === 0 ? t.pass : t.accent,
|
||||
}}>
|
||||
{D.lastSummary?.failed === 0
|
||||
? 'All checks passed. Ready to start.'
|
||||
: `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
|
||||
</div>}
|
||||
<div>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id} style={{ borderBottom:`1px solid ${t.borderSoft}` }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 28 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={13}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 12.5 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{
|
||||
marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim,
|
||||
}}>{r?.metric}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '0 0 6px 23px', padding:'7px 10px',
|
||||
background: t.panel2, borderLeft:`2px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function WshStartBtn({ t, D, fontUI }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed||0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 1, fontWeight: 700, fontSize: 12, letterSpacing: 1.5,
|
||||
background: `${c}1f`, color: c, borderLeft:`3px solid ${c}`,
|
||||
textTransform:'uppercase', display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, background: c }}/>
|
||||
Active{warning ? ' · UDP warn' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 1, border:'none',
|
||||
background: ok ? t.accent : t.panel2, color: ok ? t.primaryFg : t.dimmer,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
|
||||
cursor: ok?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Start proxying</button>;
|
||||
}
|
||||
function WshStopBtn({ t, D, fontUI }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return <button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 1, background:'transparent',
|
||||
color: enabled ? t.text : t.dimmer, border:`1px solid ${t.border}`,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
|
||||
cursor: enabled?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Stop</button>;
|
||||
}
|
||||
function Stat({ val, lbl, t, fontMono, hl }) {
|
||||
return <div style={{ display:'flex', alignItems:'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 10, color: hl||t.dimmer, textTransform:'uppercase', letterSpacing: 0.5, fontWeight: 700 }}>{lbl}</span>
|
||||
<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
|
||||
</div>;
|
||||
}
|
||||
function WshLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0, position:'relative', zIndex: 1 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'9px 16px', display:'flex', alignItems:'center', gap: 10,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 11,
|
||||
letterSpacing: 1.5, textTransform:'uppercase',
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
|
||||
<span style={{ fontWeight: 700 }}>Logs</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 10.5, color: t.dimmer, letterSpacing: 0 }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding:'0 16px 8px' }}>
|
||||
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 1,
|
||||
padding:'3px 8px', fontSize: 10.5, color: t.dim, cursor:'pointer',
|
||||
fontFamily: fontMono, letterSpacing: 1, textTransform:'uppercase',
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY:'auto', padding:'8px 16px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.6, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 700 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.WorkshopWindow = WorkshopWindow;
|
||||
Reference in New Issue
Block a user