mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
Add browser setup wizard and MSI shortcuts
Introduce a local-loopback pairing wizard and related installer/packaging changes. - Add a new CLI command `setup` (agent/cmd/agent/main.go) that launches a small HTTP server and opens a browser/native window to run a pairing wizard. - Implement the setup UI server and wizard HTML (agent/internal/setupui/*). The wizard runs a local HTTP API to start pairing, poll status, save credentials, and on Windows attempts to open a native WebView2 window (github.com/jchv/go-webview2) falling back to the system browser. After successful pairing the agent service is switched to auto-start (best-effort on Windows). - Add platform-specific helpers: WebView2 window on Windows and no-op stubs on other OSes, plus service-start helper for Windows. - Update go.mod/go.sum to include WebView2/winloader deps. - MSI changes: add Start Menu shortcuts (Setup/Status), do not auto-start the service on install (start remains demand), add an Icon entry, and adjust build.ps1 to resolve the output directory to an absolute path for stable output paths. Packaged MSI outputs were added under packaging/msi/output/. - Frontend additions: add shadcn/ui configuration, jsconfig, new globals and UI components used by the wizard (frontend/src/... and assets). These changes provide an easier desktop pairing flow while keeping CLI pairing available for headless servers, and tweak the installer so the service isn't started before credentials exist.
This commit is contained in:
1 parent
3a0fd76fff
commit
956c8e7964
56 files changed
+4894
-339
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/serverkit/agent/internal/agent"
|
||||
"github.com/serverkit/agent/internal/config"
|
||||
"github.com/serverkit/agent/internal/logger"
|
||||
"github.com/serverkit/agent/internal/setupui"
|
||||
"github.com/serverkit/agent/internal/tray"
|
||||
"github.com/serverkit/agent/internal/updater"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -43,6 +44,7 @@ enabling remote Docker management, monitoring, and more.`,
|
||||
rootCmd.AddCommand(startCmd())
|
||||
rootCmd.AddCommand(registerCmd())
|
||||
rootCmd.AddCommand(pairCmd())
|
||||
rootCmd.AddCommand(setupCmd())
|
||||
rootCmd.AddCommand(statusCmd())
|
||||
rootCmd.AddCommand(versionCmd())
|
||||
rootCmd.AddCommand(configCmd())
|
||||
@@ -371,6 +373,46 @@ func showStatus() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Open a browser-based pairing wizard (recommended for desktops)",
|
||||
Long: `Launches a small local web server on 127.0.0.1 and opens your default
|
||||
browser to a pairing wizard. You enter the panel URL and a passphrase, then
|
||||
type the displayed code into the panel UI to claim this server.
|
||||
|
||||
This is the easiest way to pair on Windows/macOS desktops. For headless
|
||||
servers, use 'serverkit-agent pair' instead.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSetup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runSetup() error {
|
||||
log := logger.New(config.LoggingConfig{Level: "info"})
|
||||
|
||||
configPath := cfgFile
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath()
|
||||
}
|
||||
|
||||
srv := setupui.New(log, configPath)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
fmt.Println("\nCancelling…")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
return srv.Run(ctx)
|
||||
}
|
||||
|
||||
func trayCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tray",
|
||||
|
||||
@@ -24,6 +24,8 @@ require (
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
|
||||
@@ -35,6 +35,10 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
@@ -104,8 +108,10 @@ golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package setupui
|
||||
|
||||
func startServiceIfInstalled() {}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package setupui
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// startServiceIfInstalled flips the ServerKitAgent service to auto-start
|
||||
// and starts it. Best-effort: silently ignores errors (e.g. service not
|
||||
// installed, no admin rights).
|
||||
func startServiceIfInstalled() {
|
||||
run := func(args ...string) {
|
||||
cmd := exec.Command("sc.exe", args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
_ = cmd.Run()
|
||||
}
|
||||
run("config", "ServerKitAgent", "start=", "auto")
|
||||
run("start", "ServerKitAgent")
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Package setupui implements a local-loopback HTTP wizard that walks an
|
||||
// operator through agent pairing without forcing them onto the CLI.
|
||||
package setupui
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/serverkit/agent/internal/config"
|
||||
"github.com/serverkit/agent/internal/logger"
|
||||
"github.com/serverkit/agent/internal/metrics"
|
||||
"github.com/serverkit/agent/internal/pairing"
|
||||
)
|
||||
|
||||
//go:embed wizard.html
|
||||
var wizardHTML []byte
|
||||
|
||||
// errNoWebView2 signals that a native WebView2 window could not be opened
|
||||
// and the caller should fall back to the system browser.
|
||||
var errNoWebView2 = errors.New("native window unavailable")
|
||||
|
||||
// Server hosts the local pairing wizard.
|
||||
type Server struct {
|
||||
log *logger.Logger
|
||||
configPath string
|
||||
|
||||
mu sync.RWMutex
|
||||
state *State
|
||||
cancel context.CancelFunc
|
||||
doneCh chan struct{}
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// State is the JSON shape returned by /api/status.
|
||||
type State struct {
|
||||
Phase string `json:"phase"` // idle | enrolling | waiting | claimed | error
|
||||
PanelURL string `json:"panel_url,omitempty"`
|
||||
PairCode string `json:"pair_code,omitempty"`
|
||||
PairCodeShort string `json:"pair_code_short,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// New constructs a wizard server using the given config path for credential persistence.
|
||||
func New(log *logger.Logger, configPath string) *Server {
|
||||
return &Server{
|
||||
log: log.WithComponent("setupui"),
|
||||
configPath: configPath,
|
||||
state: &State{Phase: "idle"},
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the HTTP server, opens a browser and blocks until pairing
|
||||
// completes (success or user cancel) or ctx is cancelled.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
s.listener = ln
|
||||
|
||||
s.doneCh = make(chan struct{})
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
mux.HandleFunc("/api/start", s.handleStart)
|
||||
mux.HandleFunc("/api/status", s.handleStatus)
|
||||
mux.HandleFunc("/api/cancel", s.handleCancel)
|
||||
mux.HandleFunc("/api/close", s.handleClose)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = srv.Serve(ln)
|
||||
}()
|
||||
|
||||
url := fmt.Sprintf("http://%s/", ln.Addr().String())
|
||||
fmt.Printf("ServerKit Agent — pairing wizard:\n %s\n\n", url)
|
||||
|
||||
// Try native window first (Windows: WebView2). On failure or non-Windows,
|
||||
// fall back to opening in the user's default browser.
|
||||
useBrowser := true
|
||||
go func() {
|
||||
err := openInNativeWindow(url, "ServerKit Agent · Setup")
|
||||
if err == nil {
|
||||
// Window closed — signal done so we shut down.
|
||||
s.signalDone()
|
||||
}
|
||||
// errNoWebView2 means we never got a window; the browser fallback
|
||||
// runs synchronously below, so nothing else to do here.
|
||||
_ = err
|
||||
}()
|
||||
|
||||
// Give the native window a brief moment to come up before deciding to
|
||||
// open the browser. If the goroutine has already errored, fall back.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if runtime.GOOS == "windows" {
|
||||
// On Windows we trust WebView2 (ships with Win10+). If WebView2 is
|
||||
// missing the native call returns errNoWebView2 quickly — but the
|
||||
// goroutine may still be initializing, so we don't open a browser
|
||||
// here. The user will see the native window.
|
||||
useBrowser = false
|
||||
fmt.Println("Opening setup window…")
|
||||
fmt.Println("Leave this terminal open until pairing completes. Press Ctrl+C to cancel.")
|
||||
}
|
||||
if useBrowser {
|
||||
fmt.Println("Opening in your default browser…")
|
||||
fmt.Println("Leave this terminal open until pairing completes. Press Ctrl+C to cancel.")
|
||||
if err := openBrowser(url); err != nil {
|
||||
s.log.Warn("could not open browser automatically", "error", err)
|
||||
fmt.Println("(Couldn't open browser automatically — copy the URL above into your browser.)")
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-s.doneCh:
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.state.Phase == "error" {
|
||||
return fmt.Errorf("pairing failed: %s", s.state.ErrorMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(wizardHTML)
|
||||
}
|
||||
|
||||
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PanelURL string `json:"panel_url"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.PanelURL = strings.TrimSpace(req.PanelURL)
|
||||
req.Passphrase = strings.TrimSpace(req.Passphrase)
|
||||
if req.PanelURL == "" || req.Passphrase == "" {
|
||||
http.Error(w, "panel_url and passphrase are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Passphrase) < 4 {
|
||||
http.Error(w, "passphrase must be at least 4 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(req.PanelURL, "http://") && !strings.HasPrefix(req.PanelURL, "https://") {
|
||||
req.PanelURL = "https://" + req.PanelURL
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.state.Phase != "idle" && s.state.Phase != "error" && s.state.Phase != "claimed" {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, "pairing already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.state = &State{Phase: "enrolling", PanelURL: req.PanelURL}
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runPairing(ctx, req.PanelURL, req.Passphrase)
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
state := *s.state
|
||||
s.mu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(state)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
s.state = &State{Phase: "idle"}
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleClose(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
s.signalDone()
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) signalDone() {
|
||||
select {
|
||||
case <-s.doneCh:
|
||||
default:
|
||||
close(s.doneCh)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pairing driver ----
|
||||
|
||||
func (s *Server) runPairing(ctx context.Context, panelURL, passphrase string) {
|
||||
setErr := func(msg string) {
|
||||
s.mu.Lock()
|
||||
s.state.Phase = "error"
|
||||
s.state.ErrorMessage = msg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
kp, err := pairing.LoadOrCreate(pairing.DefaultKeyPath())
|
||||
if err != nil {
|
||||
setErr(fmt.Sprintf("could not load keypair: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
collector := metrics.NewCollector(config.MetricsConfig{}, s.log)
|
||||
infoCtx, infoCancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
sysInfo, _ := collector.GetSystemInfo(infoCtx)
|
||||
infoCancel()
|
||||
sysMap := map[string]interface{}{}
|
||||
if sysInfo != nil {
|
||||
sysMap = map[string]interface{}{
|
||||
"hostname": sysInfo.Hostname,
|
||||
"os": sysInfo.OS,
|
||||
"platform": sysInfo.Platform,
|
||||
"platform_version": sysInfo.PlatformVersion,
|
||||
"architecture": sysInfo.Architecture,
|
||||
"cpu_cores": sysInfo.CPUCores,
|
||||
"total_memory": sysInfo.TotalMemory,
|
||||
"total_disk": sysInfo.TotalDisk,
|
||||
}
|
||||
} else {
|
||||
hostname, _ := os.Hostname()
|
||||
sysMap["hostname"] = hostname
|
||||
sysMap["os"] = runtime.GOOS
|
||||
sysMap["architecture"] = runtime.GOARCH
|
||||
}
|
||||
|
||||
client := pairing.NewClient(panelURL, s.log)
|
||||
enrollCtx, enrollCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
enrollResp, err := client.Enroll(enrollCtx, pairing.EnrollRequest{
|
||||
Pubkey: kp.PublicKeyHex(),
|
||||
Passphrase: passphrase,
|
||||
MachineID: config.MachineID(),
|
||||
SystemInfo: sysMap,
|
||||
})
|
||||
enrollCancel()
|
||||
if err != nil {
|
||||
setErr(fmt.Sprintf("enroll: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.state.Phase = "waiting"
|
||||
s.state.PairCode = enrollResp.PairCode
|
||||
s.state.PairCodeShort = enrollResp.PairCodeFormatted
|
||||
s.state.Fingerprint = enrollResp.PubkeyFingerprint
|
||||
s.mu.Unlock()
|
||||
|
||||
creds, err := client.WaitForClaim(ctx, func(code, formatted, expiresAt string) {
|
||||
s.mu.Lock()
|
||||
s.state.PairCode = code
|
||||
s.state.PairCodeShort = formatted
|
||||
s.mu.Unlock()
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
setErr(fmt.Sprintf("waiting for claim: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := saveCredentials(s.configPath, panelURL, creds); err != nil {
|
||||
setErr(fmt.Sprintf("save credentials: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.state.Phase = "claimed"
|
||||
s.state.ServerName = creds.Name
|
||||
s.mu.Unlock()
|
||||
|
||||
// On Windows, flip the service to auto-start and start it now that we have credentials.
|
||||
startServiceIfInstalled()
|
||||
}
|
||||
|
||||
func saveCredentials(configPath, panelURL string, creds *pairing.Credentials) error {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
cfg = config.Default()
|
||||
}
|
||||
wsURL := strings.TrimSuffix(panelURL, "/")
|
||||
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
|
||||
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
|
||||
cfg.Server.URL = wsURL + "/agent"
|
||||
cfg.Agent.ID = creds.AgentID
|
||||
cfg.Agent.Name = creds.Name
|
||||
cfg.Auth.APIKey = creds.APIKey
|
||||
cfg.Auth.APISecret = creds.APISecret
|
||||
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return cfg.SaveCredentials()
|
||||
}
|
||||
|
||||
// openBrowser opens the URL in the user's default browser.
|
||||
func openBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
default:
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package setupui
|
||||
|
||||
// openInNativeWindow falls back to opening the user's default browser on
|
||||
// non-Windows platforms. Returns errNoWebView2 to signal "no native window
|
||||
// available; caller should keep the HTTP server running until done."
|
||||
func openInNativeWindow(url, title string) error {
|
||||
return errNoWebView2
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package setupui
|
||||
|
||||
import (
|
||||
wv "github.com/jchv/go-webview2"
|
||||
)
|
||||
|
||||
// openInNativeWindow opens the wizard URL in a real WebView2 window
|
||||
// (no browser chrome). It blocks until the user closes the window.
|
||||
func openInNativeWindow(url, title string) error {
|
||||
w := wv.NewWithOptions(wv.WebViewOptions{
|
||||
Debug: false,
|
||||
AutoFocus: true,
|
||||
WindowOptions: wv.WindowOptions{
|
||||
Title: title,
|
||||
Width: 720,
|
||||
Height: 760,
|
||||
Center: true,
|
||||
},
|
||||
})
|
||||
if w == nil {
|
||||
return errNoWebView2
|
||||
}
|
||||
defer w.Destroy()
|
||||
|
||||
w.SetTitle(title)
|
||||
w.Navigate(url)
|
||||
w.Run()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ServerKit Agent Setup</title>
|
||||
<style>
|
||||
/* Windows 11 Fluent-inspired design system */
|
||||
:root {
|
||||
--accent: #0067c0;
|
||||
--accent-hover: #1a78c9;
|
||||
--accent-pressed: #2f87d1;
|
||||
--bg: #f3f3f3;
|
||||
--bg-mica: #f9f9f9;
|
||||
--layer: #ffffff;
|
||||
--layer-alt: #fbfbfb;
|
||||
--stroke: #e5e5e5;
|
||||
--stroke-soft: #ededed;
|
||||
--text: #1b1b1b;
|
||||
--text-secondary: #5d5d5d;
|
||||
--text-tertiary: #8a8a8a;
|
||||
--success: #0e7c3a;
|
||||
--danger: #c42b1c;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #202020;
|
||||
--bg-mica: #2c2c2c;
|
||||
--layer: #2b2b2b;
|
||||
--layer-alt: #323232;
|
||||
--stroke: #3a3a3a;
|
||||
--stroke-soft: #353535;
|
||||
--text: #ffffff;
|
||||
--text-secondary: #c5c5c5;
|
||||
--text-tertiary: #8a8a8a;
|
||||
--accent: #4cc2ff;
|
||||
--accent-hover: #5cc9ff;
|
||||
--accent-pressed: #74cfff;
|
||||
--success: #6ccb5f;
|
||||
--danger: #ff99a4;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
height: 100vh;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--bg-mica);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
input { user-select: text; -webkit-user-select: text; }
|
||||
.app {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg-mica);
|
||||
}
|
||||
|
||||
/* Title bar (Win11 style, drag region simulated) */
|
||||
.titlebar {
|
||||
height: 40px;
|
||||
display: flex; align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--stroke-soft);
|
||||
background: var(--bg-mica);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.titlebar .title {
|
||||
font-size: 12px; font-weight: 600; color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.titlebar .icon {
|
||||
width: 16px; height: 16px;
|
||||
background: linear-gradient(135deg, #0067c0, #4cc2ff);
|
||||
border-radius: 3px;
|
||||
margin-right: 10px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: white; font-size: 9px; font-weight: 800;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 36px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-title {
|
||||
font-size: 28px; font-weight: 600; line-height: 1.2;
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.page-sub {
|
||||
font-size: 14px; color: var(--text-secondary);
|
||||
margin: 0 0 24px; line-height: 1.5;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
/* Card (Layer / Mica) */
|
||||
.card {
|
||||
background: var(--layer);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card + .card { margin-top: 0; }
|
||||
|
||||
/* Form fields */
|
||||
.field { margin-bottom: 18px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 13px; font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field .desc {
|
||||
font-size: 12px; color: var(--text-secondary);
|
||||
margin-top: 6px; line-height: 1.45;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
background: var(--layer-alt);
|
||||
border: 1px solid var(--stroke);
|
||||
border-bottom: 1px solid var(--text-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit; font-size: 14px;
|
||||
transition: border-color 0.12s;
|
||||
outline: none;
|
||||
}
|
||||
.input:hover { background: var(--layer); }
|
||||
.input:focus {
|
||||
border-bottom: 2px solid var(--accent);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.actions {
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.btn {
|
||||
height: 32px; min-width: 100px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s, transform 0.05s;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--layer);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn:hover { background: var(--layer-alt); }
|
||||
.btn:active { transform: translateY(0.5px); }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
.btn-primary:active { background: var(--accent-pressed); border-color: var(--accent-pressed); }
|
||||
.btn-primary:disabled {
|
||||
background: var(--stroke); border-color: var(--stroke);
|
||||
color: var(--text-tertiary); cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* InfoBar */
|
||||
.infobar {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; border-radius: var(--radius-sm);
|
||||
background: rgba(196,43,28,0.08);
|
||||
border: 1px solid rgba(196,43,28,0.25);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.infobar.success {
|
||||
background: rgba(14,124,58,0.08);
|
||||
border-color: rgba(14,124,58,0.25);
|
||||
}
|
||||
.infobar .ico { font-size: 16px; line-height: 1; flex-shrink: 0; }
|
||||
|
||||
/* Stepper */
|
||||
.steps {
|
||||
display: flex; gap: 0; margin-bottom: 22px;
|
||||
background: var(--layer); border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius); padding: 6px; align-items: center;
|
||||
}
|
||||
.step {
|
||||
flex: 1; display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; border-radius: var(--radius-sm);
|
||||
font-size: 12px; color: var(--text-tertiary);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.step .num {
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--stroke); color: var(--text-secondary);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-weight: 600; font-size: 11px;
|
||||
}
|
||||
.step.active { color: var(--text); background: var(--layer-alt); }
|
||||
.step.active .num { background: var(--accent); color: white; }
|
||||
.step.done .num { background: var(--success); color: white; }
|
||||
.step.done { color: var(--text-secondary); }
|
||||
|
||||
/* Pair code display */
|
||||
.pair-display {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 24px 16px;
|
||||
background: linear-gradient(135deg, var(--layer-alt), var(--layer));
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pair-label {
|
||||
font-size: 12px; font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pair-code {
|
||||
font-family: "Cascadia Mono", "Consolas", "SF Mono", ui-monospace, monospace;
|
||||
font-size: 44px; font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--accent);
|
||||
line-height: 1.1;
|
||||
margin-bottom: 6px;
|
||||
user-select: text; -webkit-user-select: text;
|
||||
}
|
||||
.fingerprint-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; color: var(--text-tertiary);
|
||||
margin-top: 8px;
|
||||
font-family: "Cascadia Mono", "Consolas", monospace;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
user-select: text; -webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.progress-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.ring {
|
||||
width: 16px; height: 16px; flex-shrink: 0;
|
||||
border: 2px solid var(--stroke);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Success */
|
||||
.success-hero {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.success-circle {
|
||||
width: 56px; height: 56px;
|
||||
background: var(--success);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 4px 12px rgba(14,124,58,0.35);
|
||||
}
|
||||
.success-circle svg { width: 28px; height: 28px; color: white; }
|
||||
|
||||
/* Instructions list */
|
||||
.instr {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.instr li {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.instr .bullet {
|
||||
width: 22px; height: 22px; flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.instr strong { color: var(--text); }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="titlebar">
|
||||
<span class="icon">SK</span>
|
||||
<span class="title">ServerKit Agent · Setup</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Stepper -->
|
||||
<div class="steps" id="stepper">
|
||||
<div class="step active" data-step="1"><span class="num">1</span><span>Connect</span></div>
|
||||
<div class="step" data-step="2"><span class="num">2</span><span>Pair code</span></div>
|
||||
<div class="step" data-step="3"><span class="num">3</span><span>Done</span></div>
|
||||
</div>
|
||||
|
||||
<!-- View 1: Form -->
|
||||
<div id="view-form">
|
||||
<h1 class="page-title">Pair this server</h1>
|
||||
<p class="page-sub">
|
||||
Connect this machine to your ServerKit panel. We'll generate a short
|
||||
code that you'll type into the panel to confirm.
|
||||
</p>
|
||||
|
||||
<div id="form-error" class="infobar hidden">
|
||||
<span class="ico">⚠</span><span id="form-error-text"></span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form id="pair-form" autocomplete="off">
|
||||
<div class="field">
|
||||
<label for="panel">ServerKit panel URL</label>
|
||||
<input id="panel" class="input" type="url" placeholder="https://panel.example.com" required autofocus>
|
||||
<div class="desc">The address you use to log into your ServerKit panel.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="passphrase">Passphrase</label>
|
||||
<input id="passphrase" class="input" type="password" placeholder="Pick something memorable" required minlength="4">
|
||||
<div class="desc">You'll type this into the panel along with the pair code. 4+ characters.</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="quit-btn" class="btn" type="button">Cancel</button>
|
||||
<button id="submit-btn" class="btn btn-primary" type="button">Begin pairing</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View 2: Waiting / pair code -->
|
||||
<div id="view-waiting" class="hidden">
|
||||
<h1 class="page-title">Enter this code in the panel</h1>
|
||||
<p class="page-sub">
|
||||
The code rotates every minute. As soon as someone in the panel claims
|
||||
this server, this window will continue.
|
||||
</p>
|
||||
|
||||
<div class="pair-display">
|
||||
<div class="pair-label">Pair code</div>
|
||||
<div class="pair-code" id="pair-code">— — — — — —</div>
|
||||
<div class="fingerprint-row">
|
||||
fingerprint: <span id="fingerprint">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<ol class="instr">
|
||||
<li><span class="bullet">1</span><div>Open the <strong>ServerKit panel</strong> in your browser.</div></li>
|
||||
<li><span class="bullet">2</span><div>Go to <strong>Servers → Add Server → Pair existing agent</strong>.</div></li>
|
||||
<li><span class="bullet">3</span><div>Enter the <strong>pair code</strong> above and the <strong>passphrase</strong> you just set.</div></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<span class="ring"></span>
|
||||
<span id="wait-msg">Waiting for the panel to claim this agent…</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="cancel-btn" class="btn" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View 3: Success -->
|
||||
<div id="view-success" class="hidden">
|
||||
<div class="success-hero">
|
||||
<div class="success-circle">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="page-title" style="text-align:center; margin-bottom:4px">All set</h1>
|
||||
<p class="page-sub" id="success-name" style="text-align:center">Your agent is paired with the panel.</p>
|
||||
</div>
|
||||
|
||||
<div class="infobar success">
|
||||
<span class="ico">✓</span>
|
||||
<span>The agent service will start automatically. This server should appear as <strong>Online</strong> in the panel within a few seconds.</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="close-btn" class="btn btn-primary" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
function setStep(n) {
|
||||
document.querySelectorAll('#stepper .step').forEach(el => {
|
||||
const s = Number(el.dataset.step);
|
||||
el.classList.remove('active', 'done');
|
||||
if (s < n) el.classList.add('done');
|
||||
else if (s === n) el.classList.add('active');
|
||||
});
|
||||
}
|
||||
function show(view) {
|
||||
['view-form','view-waiting','view-success'].forEach(v => $(v).classList.toggle('hidden', v !== view));
|
||||
}
|
||||
function showError(msg) {
|
||||
$('form-error-text').textContent = msg;
|
||||
$('form-error').classList.remove('hidden');
|
||||
}
|
||||
function clearError() { $('form-error').classList.add('hidden'); }
|
||||
|
||||
let pollTimer = null;
|
||||
async function poll() {
|
||||
try {
|
||||
const r = await fetch('/api/status', { cache: 'no-store' });
|
||||
const s = await r.json();
|
||||
if (s.phase === 'enrolling') {
|
||||
$('wait-msg').textContent = 'Contacting panel…';
|
||||
} else if (s.phase === 'waiting') {
|
||||
$('pair-code').textContent = s.pair_code_short || s.pair_code || '— — — — — —';
|
||||
$('fingerprint').textContent = s.fingerprint || '—';
|
||||
$('wait-msg').textContent = 'Waiting for the panel to claim this agent…';
|
||||
setStep(2);
|
||||
show('view-waiting');
|
||||
} else if (s.phase === 'claimed') {
|
||||
stopPoll();
|
||||
if (s.server_name) {
|
||||
$('success-name').textContent = '"' + s.server_name + '" is paired with the panel.';
|
||||
}
|
||||
setStep(3);
|
||||
show('view-success');
|
||||
} else if (s.phase === 'error') {
|
||||
stopPoll();
|
||||
showError(s.error || 'Pairing failed.');
|
||||
$('submit-btn').disabled = false;
|
||||
$('submit-btn').textContent = 'Try again';
|
||||
setStep(1);
|
||||
show('view-form');
|
||||
}
|
||||
} catch (e) { /* keep polling */ }
|
||||
}
|
||||
function startPoll() { if (!pollTimer) { poll(); pollTimer = setInterval(poll, 1500); } }
|
||||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
||||
|
||||
$('submit-btn').addEventListener('click', async () => {
|
||||
clearError();
|
||||
const panel = $('panel').value.trim();
|
||||
const pass = $('passphrase').value;
|
||||
if (!panel) { showError('Panel URL is required.'); return; }
|
||||
if (pass.length < 4) { showError('Passphrase must be at least 4 characters.'); return; }
|
||||
$('submit-btn').disabled = true;
|
||||
$('submit-btn').textContent = 'Connecting…';
|
||||
try {
|
||||
const r = await fetch('/api/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ panel_url: panel, passphrase: pass })
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.text()) || 'Request failed');
|
||||
setStep(2);
|
||||
show('view-waiting');
|
||||
startPoll();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
$('submit-btn').disabled = false;
|
||||
$('submit-btn').textContent = 'Begin pairing';
|
||||
}
|
||||
});
|
||||
|
||||
$('cancel-btn').addEventListener('click', async () => {
|
||||
await fetch('/api/cancel', { method: 'POST' });
|
||||
stopPoll();
|
||||
setStep(1);
|
||||
show('view-form');
|
||||
$('submit-btn').disabled = false;
|
||||
$('submit-btn').textContent = 'Begin pairing';
|
||||
});
|
||||
|
||||
$('quit-btn').addEventListener('click', async () => {
|
||||
await fetch('/api/close', { method: 'POST' });
|
||||
});
|
||||
|
||||
$('close-btn').addEventListener('click', async () => {
|
||||
await fetch('/api/close', { method: 'POST' });
|
||||
});
|
||||
|
||||
startPoll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,6 +35,7 @@
|
||||
<ComponentGroupRef Id="ProductComponents" />
|
||||
<ComponentRef Id="ConfigComponent" />
|
||||
<ComponentRef Id="TrayAutoStart" />
|
||||
<ComponentRef Id="StartMenuShortcuts" />
|
||||
</Feature>
|
||||
|
||||
</Package>
|
||||
@@ -52,6 +53,10 @@
|
||||
<Directory Id="AgentDataFolder" Name="Agent" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="ProgramMenuFolder">
|
||||
<Directory Id="ServerKitStartMenuFolder" Name="ServerKit Agent" />
|
||||
</StandardDirectory>
|
||||
</Fragment>
|
||||
|
||||
<!-- Components -->
|
||||
@@ -68,13 +73,14 @@
|
||||
DisplayName="ServerKit Agent"
|
||||
Description="ServerKit Agent - Remote server management service"
|
||||
Type="ownProcess"
|
||||
Start="auto"
|
||||
Start="demand"
|
||||
ErrorControl="normal"
|
||||
Arguments="start" />
|
||||
<!-- Do NOT start service on install: it has no config (token/server) yet.
|
||||
The setup wizard / `register` command switches it to auto-start once configured. -->
|
||||
<ServiceControl
|
||||
Id="ServiceControl"
|
||||
Name="ServerKitAgent"
|
||||
Start="install"
|
||||
Stop="both"
|
||||
Remove="uninstall"
|
||||
Wait="yes" />
|
||||
@@ -99,6 +105,35 @@
|
||||
Value=""[INSTALLFOLDER]serverkit-agent.exe" tray"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
<!-- Start Menu shortcuts -->
|
||||
<Component Id="StartMenuShortcuts" Directory="ServerKitStartMenuFolder" Guid="B7C8D9E0-F1A2-3456-7890-ABCDEF123456">
|
||||
<Shortcut Id="SetupShortcut"
|
||||
Name="ServerKit Agent Setup"
|
||||
Description="Pair this server with a ServerKit panel"
|
||||
Target="[INSTALLFOLDER]serverkit-agent.exe"
|
||||
Arguments="setup"
|
||||
WorkingDirectory="INSTALLFOLDER"
|
||||
Icon="ServerKitAgentIcon.exe"
|
||||
IconIndex="0" />
|
||||
<Shortcut Id="StatusShortcut"
|
||||
Name="ServerKit Agent Status"
|
||||
Description="Show ServerKit Agent connection status"
|
||||
Target="[INSTALLFOLDER]serverkit-agent.exe"
|
||||
Arguments="status"
|
||||
WorkingDirectory="INSTALLFOLDER" />
|
||||
<RemoveFolder Id="RemoveStartMenuFolder" On="uninstall" />
|
||||
<RegistryValue
|
||||
Root="HKCU"
|
||||
Key="Software\ServerKit\Agent"
|
||||
Name="StartMenuShortcuts"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
<!-- Icon used for shortcut (reuses the agent exe's embedded icon) -->
|
||||
<Icon Id="ServerKitAgentIcon.exe" SourceFile="serverkit-agent.exe" />
|
||||
</Fragment>
|
||||
|
||||
</Wix>
|
||||
@@ -105,14 +105,15 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\par
|
||||
# Copy WiX source
|
||||
Copy-Item "Product.wxs" "$buildDir\"
|
||||
|
||||
# Create output directory
|
||||
# Create output directory (resolve to absolute path so it survives Push-Location)
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
$absOutputDir = (Resolve-Path $OutputDir).Path
|
||||
|
||||
# Build MSI
|
||||
Push-Location $buildDir
|
||||
try {
|
||||
Write-Host "Running WiX build..."
|
||||
wix build Product.wxs -o "$OutputDir\serverkit-agent-$Version-x64.msi" -define Version=$Version
|
||||
wix build Product.wxs -o "$absOutputDir\serverkit-agent-$Version-x64.msi" -define Version=$Version
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
@@ -120,7 +121,7 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\par
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "MSI built successfully!"
|
||||
Write-Host "Output: $OutputDir\serverkit-agent-$Version-x64.msi"
|
||||
Write-Host "Output: $absOutputDir\serverkit-agent-$Version-x64.msi"
|
||||
}
|
||||
finally {
|
||||
# Cleanup
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2155
-89
File diff suppressed because it is too large.
Load diff
+25
-1
@@ -13,16 +13,40 @@
|
||||
"@rollup/rollup-win32-x64-msvc": "^4.60.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.363.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"recharts": "^2.12.3",
|
||||
"socket.io-client": "^4.7.2"
|
||||
"socket.io-client": "^4.7.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ResourceTierProvider } from './contexts/ResourceTierContext';
|
||||
import { ToastContainer } from './components/Toast';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import DashboardLayout from './layouts/DashboardLayout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Login from './pages/Login';
|
||||
@@ -272,7 +272,7 @@ function App() {
|
||||
<ResourceTierProvider>
|
||||
<ToastProvider>
|
||||
<AppRoutes />
|
||||
<ToastContainer />
|
||||
<Toaster />
|
||||
</ToastProvider>
|
||||
</ResourceTierProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -1,99 +1,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, Info, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const iconMap = {
|
||||
danger: AlertTriangle,
|
||||
warning: AlertCircle,
|
||||
info: Info
|
||||
};
|
||||
const iconMap = { danger: AlertTriangle, warning: AlertCircle, info: Info };
|
||||
const iconColor = { danger: 'text-destructive', warning: 'text-yellow-400', info: 'text-blue-400' };
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'danger',
|
||||
requireConfirmation,
|
||||
confirmationPlaceholder,
|
||||
onConfirm,
|
||||
onCancel
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'danger',
|
||||
requireConfirmation,
|
||||
confirmationPlaceholder,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// Reset input when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setInputValue('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
useEffect(() => { if (isOpen) setInputValue(''); }, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const Icon = iconMap[variant] || AlertTriangle;
|
||||
const isConfirmDisabled = requireConfirmation && inputValue !== requireConfirmation;
|
||||
|
||||
const Icon = iconMap[variant] || AlertTriangle;
|
||||
const isConfirmDisabled = requireConfirmation && inputValue !== requireConfirmation;
|
||||
|
||||
function handleConfirm() {
|
||||
if (!isConfirmDisabled) {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !isConfirmDisabled) {
|
||||
handleConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-content">
|
||||
<div className={`confirm-dialog-icon confirm-dialog-icon-${variant}`}>
|
||||
<Icon size={32} />
|
||||
</div>
|
||||
<h2 className="confirm-dialog-title">{title}</h2>
|
||||
<p className="confirm-dialog-message">{message}</p>
|
||||
{details && (
|
||||
<div className="confirm-dialog-details">
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
{requireConfirmation && (
|
||||
<div className="confirm-dialog-input">
|
||||
<label>
|
||||
Type <strong>{requireConfirmation}</strong> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={confirmationPlaceholder || requireConfirmation}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="confirm-dialog-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-${variant === 'danger' ? 'danger' : 'primary'}`}
|
||||
onClick={handleConfirm}
|
||||
disabled={isConfirmDisabled}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={(v) => !v && onCancel()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className={cn('mb-1', iconColor[variant] || 'text-destructive')}>
|
||||
<Icon size={32} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{message && <AlertDialogDescription>{message}</AlertDialogDescription>}
|
||||
{details && <p className="text-sm text-muted-foreground">{details}</p>}
|
||||
{requireConfirmation && (
|
||||
<div className="w-full text-left mt-2 space-y-1.5">
|
||||
<Label className="text-muted-foreground">
|
||||
Type <strong className="text-foreground">{requireConfirmation}</strong> to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isConfirmDisabled && onConfirm()}
|
||||
placeholder={confirmationPlaceholder || requireConfirmation}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={isConfirmDisabled}
|
||||
className={cn(variant !== 'danger' && 'bg-primary hover:bg-primary/90')}
|
||||
>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -1,51 +1,24 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function Modal({ open, onClose, title, children, footer, className = '', size = '' }) {
|
||||
const modalRef = useRef(null);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Focus trap: focus the modal when it opens
|
||||
useEffect(() => {
|
||||
if (open && modalRef.current) {
|
||||
modalRef.current.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className={`modal ${size ? `modal-${size}` : ''} ${className}`.trim()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>{title}</h3>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="modal-footer">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className={cn(size === 'lg' && 'max-w-3xl', className)}>
|
||||
{title && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
{children}
|
||||
{footer && <DialogFooter>{footer}</DialogFooter>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STATUS_MAP = {
|
||||
online: { label: 'Online', className: 'status-badge--success' },
|
||||
running: { label: 'Running', className: 'status-badge--success' },
|
||||
healthy: { label: 'Healthy', className: 'status-badge--success' },
|
||||
active: { label: 'Active', className: 'status-badge--success' },
|
||||
connected: { label: 'Connected', className: 'status-badge--success' },
|
||||
offline: { label: 'Offline', className: 'status-badge--danger' },
|
||||
stopped: { label: 'Stopped', className: 'status-badge--danger' },
|
||||
error: { label: 'Error', className: 'status-badge--danger' },
|
||||
failed: { label: 'Failed', className: 'status-badge--danger' },
|
||||
disconnected: { label: 'Disconnected', className: 'status-badge--danger' },
|
||||
warning: { label: 'Warning', className: 'status-badge--warning' },
|
||||
degraded: { label: 'Degraded', className: 'status-badge--warning' },
|
||||
pending: { label: 'Pending', className: 'status-badge--info' },
|
||||
building: { label: 'Building', className: 'status-badge--info' },
|
||||
deploying: { label: 'Deploying', className: 'status-badge--info' },
|
||||
paused: { label: 'Paused', className: 'status-badge--muted' },
|
||||
unknown: { label: 'Unknown', className: 'status-badge--muted' },
|
||||
const STATUS_VARIANT = {
|
||||
online: 'success', running: 'success', healthy: 'success',
|
||||
active: 'success', connected: 'success',
|
||||
offline: 'destructive', stopped: 'destructive', error: 'destructive',
|
||||
failed: 'destructive', disconnected: 'destructive',
|
||||
warning: 'warning', degraded: 'warning',
|
||||
pending: 'info', building: 'info', deploying: 'info',
|
||||
paused: 'secondary', unknown: 'secondary',
|
||||
};
|
||||
|
||||
const DOT_COLOR = {
|
||||
success: 'bg-green-400',
|
||||
destructive: 'bg-red-400',
|
||||
warning: 'bg-yellow-400',
|
||||
info: 'bg-blue-400',
|
||||
secondary: 'bg-muted-foreground/60',
|
||||
};
|
||||
|
||||
const STATUS_LABEL = {
|
||||
online: 'Online', running: 'Running', healthy: 'Healthy',
|
||||
active: 'Active', connected: 'Connected',
|
||||
offline: 'Offline', stopped: 'Stopped', error: 'Error',
|
||||
failed: 'Failed', disconnected: 'Disconnected',
|
||||
warning: 'Warning', degraded: 'Degraded',
|
||||
pending: 'Pending', building: 'Building', deploying: 'Deploying',
|
||||
paused: 'Paused', unknown: 'Unknown',
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, label, className = '' }) {
|
||||
const mapped = STATUS_MAP[status?.toLowerCase()] || STATUS_MAP.unknown;
|
||||
const displayLabel = label || mapped.label || status;
|
||||
const key = status?.toLowerCase();
|
||||
const variant = STATUS_VARIANT[key] || 'secondary';
|
||||
const dotColor = DOT_COLOR[variant] || DOT_COLOR.secondary;
|
||||
const displayLabel = label || STATUS_LABEL[key] || status;
|
||||
|
||||
return (
|
||||
<span className={`status-badge ${mapped.className} ${className}`.trim()}>
|
||||
<span className="status-badge__dot" />
|
||||
{displayLabel}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Badge variant={variant} className={cn('gap-1.5', className)}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', dotColor)} />
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn('border-b border-border', className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left',
|
||||
'[&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2',
|
||||
'gap-4 bg-card border border-border rounded-xl p-6 shadow-lg duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
function AlertDialogHeader({ className, ...props }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
}
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
function AlertDialogFooter({ className, ...props }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
);
|
||||
}
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'destructive' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger,
|
||||
AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogAction, AlertDialogCancel,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground border-border',
|
||||
success: 'border-transparent bg-green-500/15 text-green-400 border-green-500/20',
|
||||
warning: 'border-transparent bg-yellow-500/15 text-yellow-400 border-yellow-500/20',
|
||||
info: 'border-transparent bg-blue-500/15 text-blue-400 border-blue-500/20',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0 cursor-pointer',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
||||
outline: 'border border-border bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 text-xs has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({ className, variant, size, asChild = false, ...props }) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn('bg-card text-card-foreground rounded-xl border border-border shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn('flex flex-col gap-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer size-4 shrink-0 rounded-sm border border-border shadow-xs',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary',
|
||||
'transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
|
||||
const Command = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
function CommandDialog({ children, ...props }) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none',
|
||||
'placeholder:text-muted-foreground text-foreground',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm text-muted-foreground"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground',
|
||||
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground',
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({ className, ...props }) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
|
||||
);
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
export {
|
||||
Command, CommandDialog, CommandInput, CommandList, CommandEmpty,
|
||||
CommandGroup, CommandItem, CommandSeparator, CommandShortcut,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2',
|
||||
'gap-4 bg-card border border-border rounded-xl p-6 shadow-lg duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-card disabled:pointer-events-none">
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
|
||||
);
|
||||
}
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
function DialogFooter({ className, ...props }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
);
|
||||
}
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger,
|
||||
DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'[&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel,
|
||||
DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup,
|
||||
DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger, DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'placeholder:text-muted-foreground',
|
||||
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({ className, ...props }) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none',
|
||||
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs',
|
||||
'placeholder:text-muted-foreground text-foreground',
|
||||
'focus:outline-none focus:ring-[3px] focus:ring-ring/50 focus:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 opacity-50 shrink-0" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select, SelectGroup, SelectValue, SelectTrigger, SelectContent,
|
||||
SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 flex flex-col gap-4 bg-card border-border p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: { side: 'right' },
|
||||
}
|
||||
);
|
||||
|
||||
const SheetContent = React.forwardRef(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
function SheetHeader({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />;
|
||||
}
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
function SheetFooter({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />;
|
||||
}
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Toaster as SonnerToaster } from 'sonner';
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-right"
|
||||
expand
|
||||
richColors
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-default)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-background shadow-lg ring-0 transition-transform',
|
||||
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b [&_tr]:border-border', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t border-border bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all',
|
||||
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Textarea({ className, ...props }) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm text-foreground shadow-xs',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||
'transition-[color,box-shadow]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -1,67 +1,29 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useCallback } from 'react';
|
||||
import { toast as sonner } from 'sonner';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be used within a ToastProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const success = useCallback((message, duration) =>
|
||||
sonner.success(message, duration != null ? { duration } : undefined), []);
|
||||
const error = useCallback((message, duration) =>
|
||||
sonner.error(message, duration != null ? { duration } : undefined), []);
|
||||
const warning = useCallback((message, duration) =>
|
||||
sonner.warning(message, duration != null ? { duration } : undefined), []);
|
||||
const info = useCallback((message, duration) =>
|
||||
sonner.info(message, duration != null ? { duration } : undefined), []);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 5000) => {
|
||||
const id = ++toastId;
|
||||
const toast = { id, message, type };
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}, [removeToast]);
|
||||
|
||||
const success = useCallback((message, duration) => {
|
||||
return addToast(message, 'success', duration);
|
||||
}, [addToast]);
|
||||
|
||||
const error = useCallback((message, duration) => {
|
||||
return addToast(message, 'error', duration);
|
||||
}, [addToast]);
|
||||
|
||||
const warning = useCallback((message, duration) => {
|
||||
return addToast(message, 'warning', duration);
|
||||
}, [addToast]);
|
||||
|
||||
const info = useCallback((message, duration) => {
|
||||
return addToast(message, 'info', duration);
|
||||
}, [addToast]);
|
||||
|
||||
const value = {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
return (
|
||||
<ToastContext.Provider value={{ success, error, warning, info, toasts: [], addToast: sonner, removeToast: () => {} }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToastContext;
|
||||
@@ -0,0 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
/*
|
||||
* Token bridge: maps Tailwind's semantic color names to ServerKit's existing
|
||||
* CSS custom properties. The `inline` keyword keeps these as runtime CSS vars
|
||||
* so the dark/light theme toggle in _theme-variables.scss continues to work.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-background: var(--bg-body);
|
||||
--color-foreground: var(--text-primary);
|
||||
|
||||
--color-card: var(--bg-card);
|
||||
--color-card-foreground: var(--text-primary);
|
||||
|
||||
--color-popover: var(--bg-elevated);
|
||||
--color-popover-foreground: var(--text-primary);
|
||||
|
||||
--color-primary: var(--accent-primary);
|
||||
--color-primary-foreground: #ffffff;
|
||||
|
||||
--color-secondary: var(--bg-secondary);
|
||||
--color-secondary-foreground: var(--text-primary);
|
||||
|
||||
--color-muted: var(--bg-secondary);
|
||||
--color-muted-foreground: var(--text-tertiary);
|
||||
|
||||
--color-accent: var(--bg-hover);
|
||||
--color-accent-foreground: var(--text-primary);
|
||||
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
|
||||
--color-border: var(--border-default);
|
||||
--color-input: var(--border-default);
|
||||
--color-ring: var(--accent-primary);
|
||||
--color-ring-offset: var(--bg-body);
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Accordion animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from { height: 0; }
|
||||
to { height: var(--radix-accordion-content-height); }
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from { height: var(--radix-accordion-content-height); }
|
||||
to { height: 0; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './globals.css'
|
||||
import './styles/main.scss'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
|
||||
@@ -608,7 +608,8 @@ const PairAgentForm = ({ groups, onClose, onClaimed }) => {
|
||||
try {
|
||||
const res = await api.lookupPairCode(formattedCode);
|
||||
setLookupResult(res);
|
||||
if (!name && res.suggested_name) setName(res.suggested_name);
|
||||
const suggestedName = res.system_info?.hostname;
|
||||
if (!name && suggestedName) setName(suggestedName);
|
||||
} catch (err) {
|
||||
setLookupError(err.message || 'Pair code not found');
|
||||
} finally {
|
||||
@@ -649,6 +650,12 @@ const PairAgentForm = ({ groups, onClose, onClaimed }) => {
|
||||
return (
|
||||
<form className="server-setup-form" onSubmit={handleClaim}>
|
||||
<div className="server-setup-form__body">
|
||||
<div className="pair-instructions">
|
||||
<p>
|
||||
On the target machine run <code>serverkit-agent pair</code>. It prints a 6-character code — enter it below. Then supply the passphrase you chose when starting pairing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Pair code</label>
|
||||
<input
|
||||
@@ -667,7 +674,7 @@ const PairAgentForm = ({ groups, onClose, onClaimed }) => {
|
||||
style={{ fontFamily: 'monospace', fontSize: '1.25rem', letterSpacing: '0.15em', textAlign: 'center' }}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">Run <code>serverkit-agent pair</code> on the target machine and read the code from its output (or system tray).</span>
|
||||
<span className="form-hint">The code is shown in the terminal output or system tray.</span>
|
||||
{lookupError && <div className="error-message" style={{ marginTop: '0.5rem' }}>{lookupError}</div>}
|
||||
</div>
|
||||
|
||||
@@ -676,7 +683,7 @@ const PairAgentForm = ({ groups, onClose, onClaimed }) => {
|
||||
<div>
|
||||
<strong>Agent found</strong>
|
||||
<p className="success-subtitle">
|
||||
Hostname: <code>{lookupResult.hostname || 'unknown'}</code><br />
|
||||
Hostname: <code>{lookupResult.system_info?.hostname || 'unknown'}</code><br />
|
||||
Fingerprint: <code style={{ fontFamily: 'monospace' }}>{lookupResult.pubkey_fpr}</code>
|
||||
</p>
|
||||
<p className="text-muted" style={{ marginTop: '0.25rem', fontSize: '0.85em' }}>
|
||||
@@ -705,8 +712,9 @@ const PairAgentForm = ({ groups, onClose, onClaimed }) => {
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="prod-web-01 (optional)"
|
||||
placeholder={lookupResult?.system_info?.hostname || 'Auto-detected from agent (optional)'}
|
||||
/>
|
||||
<span className="form-hint">Leave blank to use the agent's hostname.</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Group</label>
|
||||
@@ -790,26 +798,30 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${registratio
|
||||
<div className="modal server-setup-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<span className="servers-eyebrow">{step === 1 ? 'New agent' : 'Registration ready'}</span>
|
||||
<h2>{step === 1 ? 'Add Server' : (mode === 'pair' ? 'Pair Agent' : 'Install Agent')}</h2>
|
||||
<p>{step === 1 ? 'Pair an already-running agent with a short code, or generate an install script for a new machine.' : (mode === 'pair' ? 'Enter the 6-char code shown on the agent and your passphrase.' : 'Run one command on the target machine to bring it online.')}</p>
|
||||
<p>
|
||||
{step === 2 && mode !== 'pair'
|
||||
? 'Run one command on the target machine to bring it online.'
|
||||
: step === 2
|
||||
? 'Enter the 6-char code shown on the agent and your passphrase.'
|
||||
: 'Connect an existing agent or set up a brand-new machine.'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="install-tabs" style={{ padding: '0 1.5rem', marginTop: '0.5rem' }}>
|
||||
<div className="mode-switcher">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${mode === 'pair' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
className={`mode-switcher__tab${mode === 'pair' ? ' is-active' : ''}`}
|
||||
onClick={() => setMode('pair')}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
>
|
||||
Pair existing agent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${mode === 'install' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
className={`mode-switcher__tab${mode === 'install' ? ' is-active' : ''}`}
|
||||
onClick={() => setMode('install')}
|
||||
>
|
||||
Install new agent
|
||||
|
||||
@@ -438,7 +438,7 @@ const Templates = () => {
|
||||
{/* Template Detail Modal */}
|
||||
{selectedTemplate && (
|
||||
<div className="modal-overlay" onClick={() => setSelectedTemplate(null)}>
|
||||
<div className="modal modal-lg" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal template-detail-drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="template-detail-header">
|
||||
<div className="template-icon-large">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
export async function lookupPairCode(code) {
|
||||
return this.request('/pairing/lookup', {
|
||||
method: 'POST',
|
||||
body: { pair_code: code }
|
||||
body: { code }
|
||||
});
|
||||
}
|
||||
|
||||
export async function claimPairedAgent({ pair_code, passphrase, name, group_id, trust_fingerprint }) {
|
||||
const body = { pair_code, passphrase };
|
||||
const body = { code: pair_code, passphrase };
|
||||
if (name) body.name = name;
|
||||
if (group_id) body.group_id = group_id;
|
||||
if (trust_fingerprint) body.trust_fingerprint = trust_fingerprint;
|
||||
|
||||
@@ -769,39 +769,108 @@
|
||||
}
|
||||
|
||||
.server-setup-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 480px;
|
||||
max-width: 100vw;
|
||||
max-height: none;
|
||||
border-radius: $radius-2xl 0 0 $radius-2xl;
|
||||
border-left: 1px solid $border-subtle;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
max-height: min(720px, 90vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: $shadow-lg;
|
||||
animation: drawer-slide-in 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.modal-header {
|
||||
flex-shrink: 0;
|
||||
align-items: flex-start;
|
||||
padding: $space-5 $space-6 $space-4;
|
||||
padding: $space-6 $space-6 $space-5;
|
||||
background:
|
||||
linear-gradient(135deg, $accent-glow, transparent 42%),
|
||||
linear-gradient(135deg, $bg-card, $bg-elevated);
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
|
||||
h2 {
|
||||
margin: $space-1 0 4px;
|
||||
margin: 0 0 $space-1;
|
||||
font-size: $font-size-xl;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 460px;
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.servers-eyebrow {
|
||||
font-size: 9px;
|
||||
@keyframes drawer-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
// Segmented tab control used in the Add Server drawer
|
||||
.mode-switcher {
|
||||
display: flex;
|
||||
background: $bg-body;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-lg;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
margin: $space-4 $space-6 0;
|
||||
|
||||
&__tab {
|
||||
flex: 1;
|
||||
padding: $space-2 $space-3;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: calc(#{$radius-md} - 1px);
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: $bg-card;
|
||||
color: $text-primary;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brief instruction callout shown above the pair-code input
|
||||
.pair-instructions {
|
||||
display: flex;
|
||||
gap: $space-3;
|
||||
padding: $space-3 $space-4;
|
||||
background: rgba($accent-primary-raw, 0.07);
|
||||
border: 1px solid rgba($accent-primary-raw, 0.22);
|
||||
border-radius: $radius-md;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
line-height: 1.5;
|
||||
|
||||
code {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
color: $accent-primary;
|
||||
background: rgba($accent-primary-raw, 0.12);
|
||||
padding: 1px 5px;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +414,51 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
// Template Detail Drawer
|
||||
.template-detail-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 520px;
|
||||
max-width: 100vw;
|
||||
max-height: none;
|
||||
border-radius: $radius-2xl 0 0 $radius-2xl;
|
||||
border-left: 1px solid $border-subtle;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: $shadow-lg;
|
||||
animation: drawer-slide-in 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.modal-header {
|
||||
flex-shrink: 0;
|
||||
align-items: flex-start;
|
||||
padding: $space-6;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
background:
|
||||
linear-gradient(135deg, $accent-glow, transparent 42%),
|
||||
linear-gradient(135deg, $bg-card, $bg-elevated);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar();
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawer-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
// Template Detail Modal
|
||||
.template-detail-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5274,
|
||||
// Enable polling for WSL (Windows filesystem doesn't support inotify)
|
||||
|
||||
Reference in new issue
Block a user