Compare commits
54 Commits
Author | SHA1 | Date | |
---|---|---|---|
7efa724631 | |||
22a7d1ad45 | |||
f6450fdafb | |||
7f4ca796aa | |||
0c5d0616e5 | |||
6f3a5dce64 | |||
8575155f4b | |||
41f7d3157f | |||
e72bab7086 | |||
3ca3bf8810 | |||
81713d09fd | |||
2d6af8bfa9 | |||
dad6d79740 | |||
3b20e07b17 | |||
3a4e30d372 | |||
b0ad48985a | |||
c55b3bd882 | |||
c5f7fd483e | |||
c21f82667a | |||
f7879c7ea8 | |||
22ef386ea2 | |||
51179485a1 | |||
11fda4e11f | |||
7af7d9ecd0 | |||
97178bc9a5 | |||
2561cd7d30 | |||
9c40135102 | |||
90b190b8d5 | |||
c10f4b43cb | |||
e2f804bac7 | |||
91cbb6c841 | |||
fb3ebc10ff | |||
2a098ec0d2 | |||
ee2a1fcde0 | |||
a584bc9c55 | |||
e1e961ebea | |||
f06a60c701 | |||
ef57bf4cde | |||
cd1cc6dc5f | |||
e7a8014a50 | |||
13da1c8340 | |||
3f9d315978 | |||
ca610000db | |||
33fcaaaf52 | |||
1b73642940 | |||
aa15a8c2c9 | |||
478a628b6f | |||
98b6397dcc | |||
715e4559aa | |||
d42c245c9d | |||
ff840299d6 | |||
f69c93ba91 | |||
c0f2430590 | |||
c999a500f8 |
215
app.go
215
app.go
@@ -2,12 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App struct
|
// App struct
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
ssi *ESISSO
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App application struct
|
// NewApp creates a new App application struct
|
||||||
@@ -19,9 +25,218 @@ func NewApp() *App {
|
|||||||
// so we can call the runtime methods
|
// so we can call the runtime methods
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
|
clientID := os.Getenv("EVE_SSO_CLIENT_ID")
|
||||||
|
if clientID == "" {
|
||||||
|
clientID = "77c5adb91e46459b874204ceeedb459f"
|
||||||
|
}
|
||||||
|
redirectURI := os.Getenv("EVE_SSO_REDIRECT_URI")
|
||||||
|
if redirectURI == "" {
|
||||||
|
redirectURI = "http://localhost:8080/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add location read scope so we can fetch character locations
|
||||||
|
a.ssi = NewESISSO(clientID, redirectURI, []string{
|
||||||
|
"esi-location.read_location.v1",
|
||||||
|
"esi-location.read_ship_type.v1",
|
||||||
|
"esi-mail.organize_mail.v1",
|
||||||
|
"esi-mail.read_mail.v1",
|
||||||
|
"esi-mail.send_mail.v1",
|
||||||
|
"esi-skills.read_skills.v1",
|
||||||
|
"esi-skills.read_skillqueue.v1",
|
||||||
|
"esi-wallet.read_character_wallet.v1",
|
||||||
|
"esi-wallet.read_corporation_wallet.v1",
|
||||||
|
"esi-characters.read_contacts.v1",
|
||||||
|
"esi-killmails.read_killmails.v1",
|
||||||
|
"esi-assets.read_assets.v1",
|
||||||
|
"esi-planets.manage_planets.v1",
|
||||||
|
"esi-ui.write_waypoint.v1",
|
||||||
|
"esi-characters.write_contacts.v1",
|
||||||
|
"esi-markets.structure_markets.v1",
|
||||||
|
"esi-characters.read_loyalty.v1",
|
||||||
|
"esi-characters.read_chat_channels.v1",
|
||||||
|
"esi-characters.read_medals.v1",
|
||||||
|
"esi-characters.read_standings.v1",
|
||||||
|
"esi-characters.read_agents_research.v1",
|
||||||
|
"esi-industry.read_character_jobs.v1",
|
||||||
|
"esi-markets.read_character_orders.v1",
|
||||||
|
"esi-characters.read_blueprints.v1",
|
||||||
|
"esi-characters.read_corporation_roles.v1",
|
||||||
|
"esi-location.read_online.v1",
|
||||||
|
"esi-characters.read_fatigue.v1",
|
||||||
|
"esi-killmails.read_corporation_killmails.v1",
|
||||||
|
"esi-wallet.read_corporation_wallets.v1",
|
||||||
|
"esi-characters.read_notifications.v1",
|
||||||
|
"esi-assets.read_corporation_assets.v1",
|
||||||
|
"esi-industry.read_corporation_jobs.v1",
|
||||||
|
"esi-markets.read_corporation_orders.v1",
|
||||||
|
"esi-industry.read_character_mining.v1",
|
||||||
|
"esi-industry.read_corporation_mining.v1",
|
||||||
|
"esi-planets.read_customs_offices.v1",
|
||||||
|
"esi-characters.read_titles.v1",
|
||||||
|
"esi-characters.read_fw_stats.v1",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Greet returns a greeting for the given name
|
// Greet returns a greeting for the given name
|
||||||
func (a *App) Greet(name string) string {
|
func (a *App) Greet(name string) string {
|
||||||
return fmt.Sprintf("Hello %s, It's show time!", name)
|
return fmt.Sprintf("Hello %s, It's show time!", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartESILogin begins the PKCE SSO flow and opens a browser to the EVE login page
|
||||||
|
func (a *App) StartESILogin() (string, error) {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return "", errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
url, err := a.ssi.BuildAuthorizeURL()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := a.ssi.StartCallbackServerAsync(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
runtime.BrowserOpenURL(a.ctx, url)
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ESILoginStatus() string {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return "not initialised"
|
||||||
|
}
|
||||||
|
st := a.ssi.Status()
|
||||||
|
if st.LoggedIn {
|
||||||
|
return fmt.Sprintf("logged in as %s (%d)", st.CharacterName, st.CharacterID)
|
||||||
|
}
|
||||||
|
return "not logged in"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ESILoggedIn() bool {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.ssi.Status().LoggedIn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetDestinationForAll(systemName string, clearOthers bool, addToBeginning bool) error {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
id, err := a.ssi.ResolveSystemIDByName(a.ctx, systemName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.ssi.PostWaypointForAll(id, clearOthers, addToBeginning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AddWaypointForAllByName(systemName string, addToBeginning bool) error {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
id, err := a.ssi.ResolveSystemIDByName(a.ctx, systemName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.ssi.PostWaypointForAll(id, false, addToBeginning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostRouteForAllByNames posts a full route: via names (in order) then destination at the end (clearing first)
|
||||||
|
func (a *App) PostRouteForAllByNames(destination string, vias []string) error {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
ids, err := a.ssi.ResolveSystemIDsByNames(a.ctx, append(vias, destination))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
viaIDs := ids[:len(vias)]
|
||||||
|
destID := ids[len(vias)]
|
||||||
|
return a.ssi.PostRouteForAll(destID, viaIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ListCharacters() ([]CharacterInfo, error) {
|
||||||
|
if a.ssi == nil || a.ssi.db == nil {
|
||||||
|
return nil, errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
var tokens []ESIToken
|
||||||
|
if err := a.ssi.db.Find(&tokens).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]CharacterInfo, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
list = append(list, CharacterInfo{CharacterID: t.CharacterID, CharacterName: t.CharacterName, WaypointEnabled: t.WaypointEnabled})
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCharacterLocations exposes current locations for all characters
|
||||||
|
func (a *App) GetCharacterLocations() ([]CharacterLocation, error) {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return nil, errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return a.ssi.GetCharacterLocations(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleCharacterWaypointEnabled toggles waypoint enabled status for a character
|
||||||
|
func (a *App) ToggleCharacterWaypointEnabled(characterID int64) error {
|
||||||
|
if a.ssi == nil {
|
||||||
|
return errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
return a.ssi.ToggleCharacterWaypointEnabled(characterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemJumps fetches system jump statistics from ESI
|
||||||
|
func (a *App) GetSystemJumps() ([]SystemJumps, error) {
|
||||||
|
fmt.Printf("🔍 App.GetSystemJumps() called - this should ONLY happen when toggle is ON!\n")
|
||||||
|
if a.ssi == nil {
|
||||||
|
return nil, errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return a.ssi.GetSystemJumps(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemKills fetches system kill statistics from ESI
|
||||||
|
func (a *App) GetSystemKills() ([]SystemKills, error) {
|
||||||
|
fmt.Printf("🔍 App.GetSystemKills() called - this should ONLY happen when toggle is ON!\n")
|
||||||
|
if a.ssi == nil {
|
||||||
|
return nil, errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return a.ssi.GetSystemKills(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSystemIDByName resolves a system name to its ID
|
||||||
|
func (a *App) ResolveSystemIDByName(systemName string) (int64, error) {
|
||||||
|
fmt.Printf("🔍 App.ResolveSystemIDByName() called for system: %s\n", systemName)
|
||||||
|
if a.ssi == nil {
|
||||||
|
return 0, errors.New("ESI not initialised")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return a.ssi.ResolveSystemIDByName(ctx, systemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemRegion holds system + region names from local DB
|
||||||
|
type SystemRegion struct {
|
||||||
|
System string `json:"system"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSystemsWithRegions returns all solar system names and their regions from the local SQLite DB
|
||||||
|
func (a *App) ListSystemsWithRegions() ([]SystemRegion, error) {
|
||||||
|
if a.ssi == nil || a.ssi.db == nil {
|
||||||
|
return nil, errors.New("db not initialised")
|
||||||
|
}
|
||||||
|
var rows []SystemRegion
|
||||||
|
// mapSolarSystems has regionID; mapRegions has regionName
|
||||||
|
q := `SELECT s.solarSystemName AS system, r.regionName AS region
|
||||||
|
FROM mapSolarSystems s
|
||||||
|
JOIN mapRegions r ON r.regionID = s.regionID`
|
||||||
|
if err := a.ssi.db.Raw(q).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
887
esi_sso.go
Normal file
887
esi_sso.go
Normal file
@@ -0,0 +1,887 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
issuerAuthorizeURL = "https://login.eveonline.com/v2/oauth/authorize"
|
||||||
|
issuerTokenURL = "https://login.eveonline.com/v2/oauth/token"
|
||||||
|
esiBase = "https://esi.evetech.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ESISSO struct {
|
||||||
|
clientID string
|
||||||
|
redirectURI string
|
||||||
|
scopes []string
|
||||||
|
|
||||||
|
state string
|
||||||
|
codeVerifier string
|
||||||
|
codeChallenge string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
accessToken string
|
||||||
|
refreshToken string
|
||||||
|
expiresAt time.Time
|
||||||
|
|
||||||
|
characterID int64
|
||||||
|
characterName string
|
||||||
|
|
||||||
|
callbackOnce sync.Once
|
||||||
|
server *http.Server
|
||||||
|
|
||||||
|
db *gorm.DB
|
||||||
|
|
||||||
|
nameCacheOnce sync.Once
|
||||||
|
nameToID map[string]int64 // lowercased name -> id
|
||||||
|
}
|
||||||
|
|
||||||
|
type SolarSystem struct {
|
||||||
|
SolarSystemID int64 `gorm:"column:solarSystemID;primaryKey"`
|
||||||
|
SolarSystemName string `gorm:"column:solarSystemName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SolarSystem) TableName() string { return "mapSolarSystems" }
|
||||||
|
|
||||||
|
type ESIToken struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
CharacterID int64 `gorm:"index"`
|
||||||
|
CharacterName string
|
||||||
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
WaypointEnabled bool `gorm:"default:true"`
|
||||||
|
UpdatedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterInfo struct {
|
||||||
|
CharacterID int64 `json:"character_id"`
|
||||||
|
CharacterName string `json:"character_name"`
|
||||||
|
WaypointEnabled bool `json:"waypoint_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CharacterLocation represents a character's current location
|
||||||
|
type CharacterLocation struct {
|
||||||
|
CharacterID int64 `json:"character_id"`
|
||||||
|
CharacterName string `json:"character_name"`
|
||||||
|
SolarSystemID int64 `json:"solar_system_id"`
|
||||||
|
SolarSystemName string `json:"solar_system_name"`
|
||||||
|
RetrievedAt time.Time `json:"retrieved_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type esiCharacterLocationResponse struct {
|
||||||
|
SolarSystemID int64 `json:"solar_system_id"`
|
||||||
|
StationID int64 `json:"station_id"`
|
||||||
|
StructureID int64 `json:"structure_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESI Statistics data structures
|
||||||
|
type SystemJumps struct {
|
||||||
|
SystemID int64 `json:"system_id"`
|
||||||
|
ShipJumps int64 `json:"ship_jumps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemKills struct {
|
||||||
|
SystemID int64 `json:"system_id"`
|
||||||
|
ShipKills int64 `json:"ship_kills"`
|
||||||
|
PodKills int64 `json:"pod_kills"`
|
||||||
|
NpcKills int64 `json:"npc_kills"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO {
|
||||||
|
s := &ESISSO{
|
||||||
|
clientID: clientID,
|
||||||
|
redirectURI: redirectURI,
|
||||||
|
scopes: scopes,
|
||||||
|
}
|
||||||
|
_ = s.initDB()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) initDB() error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join(home, ".industrializer", "sqlite-latest.sqlite")
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&ESIToken{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.db = db
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSystemNameByID returns the system name for an ID from the local DB, or empty if not found
|
||||||
|
func (s *ESISSO) resolveSystemNameByID(id int64) string {
|
||||||
|
if s.db == nil || id == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var ss SolarSystem
|
||||||
|
if err := s.db.Select("solarSystemName").First(&ss, "solarSystemID = ?", id).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ss.SolarSystemName
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCharacterLocations returns current locations for all stored characters
|
||||||
|
func (s *ESISSO) GetCharacterLocations(ctx context.Context) ([]CharacterLocation, error) {
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, errors.New("db not initialised")
|
||||||
|
}
|
||||||
|
var tokens []ESIToken
|
||||||
|
if err := s.db.Find(&tokens).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]CharacterLocation, 0, len(tokens))
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
for i := range tokens {
|
||||||
|
t := &tokens[i]
|
||||||
|
tok, err := s.ensureAccessTokenFor(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/characters/"+strconv.FormatInt(t.CharacterID, 10)+"/location", nil)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lr esiCharacterLocationResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := s.resolveSystemNameByID(lr.SolarSystemID)
|
||||||
|
out = append(out, CharacterLocation{CharacterID: t.CharacterID, CharacterName: t.CharacterName, SolarSystemID: lr.SolarSystemID, SolarSystemName: name, RetrievedAt: time.Now()})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemJumps fetches system jump statistics from ESI
|
||||||
|
func (s *ESISSO) GetSystemJumps(ctx context.Context) ([]SystemJumps, error) {
|
||||||
|
fmt.Printf("🚀 ESI API REQUEST: Fetching system jumps data from https://esi.evetech.net/v2/universe/system_jumps\n")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/universe/system_jumps", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-Compatibility-Date", "2025-08-26")
|
||||||
|
req.Header.Set("X-Tenant", "tranquility")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("ESI API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jumps []SystemJumps
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&jumps); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✅ ESI API SUCCESS: Fetched %d system jumps entries\n", len(jumps))
|
||||||
|
return jumps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemKills fetches system kill statistics from ESI
|
||||||
|
func (s *ESISSO) GetSystemKills(ctx context.Context) ([]SystemKills, error) {
|
||||||
|
fmt.Printf("⚔️ ESI API REQUEST: Fetching system kills data from https://esi.evetech.net/v2/universe/system_kills\n")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/universe/system_kills", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-Compatibility-Date", "2025-08-26")
|
||||||
|
req.Header.Set("X-Tenant", "tranquility")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("ESI API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var kills []SystemKills
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&kills); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✅ ESI API SUCCESS: Fetched %d system kills entries\n", len(kills))
|
||||||
|
return kills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) saveToken() {
|
||||||
|
if s.db == nil || s.characterID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := ESIToken{}
|
||||||
|
s.db.Where("character_id = ?", s.characterID).First(&t)
|
||||||
|
t.CharacterID = s.characterID
|
||||||
|
t.CharacterName = s.characterName
|
||||||
|
t.AccessToken = s.accessToken
|
||||||
|
t.RefreshToken = s.refreshToken
|
||||||
|
t.ExpiresAt = s.expiresAt
|
||||||
|
s.db.Save(&t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) loadToken() {
|
||||||
|
if s.db == nil || s.characterID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := ESIToken{}
|
||||||
|
if err := s.db.Where("character_id = ?", s.characterID).First(&t).Error; err == nil {
|
||||||
|
s.accessToken = t.AccessToken
|
||||||
|
s.refreshToken = t.RefreshToken
|
||||||
|
s.expiresAt = t.ExpiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) BuildAuthorizeURL() (string, error) {
|
||||||
|
if s.clientID == "" {
|
||||||
|
return "", errors.New("EVE_SSO_CLIENT_ID not set")
|
||||||
|
}
|
||||||
|
verifier, challenge, err := generatePKCE()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.codeVerifier = verifier
|
||||||
|
s.codeChallenge = challenge
|
||||||
|
s.state = randString(24)
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("client_id", s.clientID)
|
||||||
|
q.Set("redirect_uri", s.redirectURI)
|
||||||
|
if len(s.scopes) > 0 {
|
||||||
|
q.Set("scope", strings.Join(s.scopes, " "))
|
||||||
|
}
|
||||||
|
q.Set("state", s.state)
|
||||||
|
q.Set("code_challenge", s.codeChallenge)
|
||||||
|
q.Set("code_challenge_method", "S256")
|
||||||
|
|
||||||
|
return issuerAuthorizeURL + "?" + q.Encode(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Printf("Callback received: %s %s\n", r.Method, r.URL.String())
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
code := q.Get("code")
|
||||||
|
st := q.Get("state")
|
||||||
|
if code == "" || st == "" || st != s.state {
|
||||||
|
fmt.Printf("Invalid SSO response: code=%s, state=%s, expected_state=%s\n", code, st, s.state)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte("Invalid SSO response"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Exchanging token for code: %s\n", code)
|
||||||
|
if err := s.exchangeToken(r.Context(), code); err != nil {
|
||||||
|
fmt.Printf("Token exchange failed: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Login successful for character: %s (%d)\n", s.characterName, s.characterID)
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = io.WriteString(w, "<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
go func() {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
_ = s.server.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) StartCallbackServerAsync() error {
|
||||||
|
u, err := url.Parse(s.redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return errors.New("redirect URI must be http(s)")
|
||||||
|
}
|
||||||
|
hostPort := u.Host
|
||||||
|
if !strings.Contains(hostPort, ":") {
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
hostPort += ":443"
|
||||||
|
} else {
|
||||||
|
hostPort += ":80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Add a catch-all handler to debug what's being requested
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Printf("DEBUG: Request received - Method: %s, URL: %s, Path: %s\n", r.Method, r.URL.String(), r.URL.Path)
|
||||||
|
if r.URL.Path == u.Path {
|
||||||
|
// This is our callback, handle it
|
||||||
|
s.handleCallback(w, r)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("DEBUG: 404 - Path %s does not match expected %s\n", r.URL.Path, u.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("Not found"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.handleCallback(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", hostPort)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Callback server listening on %s%s\n", hostPort, u.Path)
|
||||||
|
s.server = &http.Server{Handler: mux}
|
||||||
|
go func() { _ = s.server.Serve(ln) }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) StartCallbackServer() error {
|
||||||
|
u, err := url.Parse(s.redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return errors.New("redirect URI must be http(s)")
|
||||||
|
}
|
||||||
|
hostPort := u.Host
|
||||||
|
if !strings.Contains(hostPort, ":") {
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
hostPort += ":443"
|
||||||
|
} else {
|
||||||
|
hostPort += ":80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
code := q.Get("code")
|
||||||
|
st := q.Get("state")
|
||||||
|
if code == "" || st == "" || st != s.state {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte("Invalid SSO response"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.exchangeToken(r.Context(), code); err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "Login successful. You can close this window.")
|
||||||
|
go func() {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
_ = s.server.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", hostPort)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.server = &http.Server{Handler: mux}
|
||||||
|
return s.server.Serve(ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) exchangeToken(ctx context.Context, code string) error {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "authorization_code")
|
||||||
|
form.Set("code", code)
|
||||||
|
form.Set("client_id", s.clientID)
|
||||||
|
form.Set("code_verifier", s.codeVerifier)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("token exchange failed: %s: %s", resp.Status, string(b))
|
||||||
|
}
|
||||||
|
var tr tokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if tr.AccessToken != "" {
|
||||||
|
s.accessToken = tr.AccessToken
|
||||||
|
}
|
||||||
|
if tr.RefreshToken != "" {
|
||||||
|
s.refreshToken = tr.RefreshToken
|
||||||
|
}
|
||||||
|
if tr.ExpiresIn > 0 {
|
||||||
|
s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
name, cid := parseTokenCharacter(tr.AccessToken)
|
||||||
|
s.characterName = name
|
||||||
|
s.characterID = cid
|
||||||
|
s.saveToken()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) refresh(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
rt := s.refreshToken
|
||||||
|
s.mu.Unlock()
|
||||||
|
if rt == "" {
|
||||||
|
return errors.New("no refresh token")
|
||||||
|
}
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "refresh_token")
|
||||||
|
form.Set("refresh_token", rt)
|
||||||
|
form.Set("client_id", s.clientID)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b))
|
||||||
|
}
|
||||||
|
var tr tokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.accessToken = tr.AccessToken
|
||||||
|
if tr.RefreshToken != "" {
|
||||||
|
s.refreshToken = tr.RefreshToken
|
||||||
|
}
|
||||||
|
if tr.ExpiresIn > 0 {
|
||||||
|
s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
name, cid := parseTokenCharacter(tr.AccessToken)
|
||||||
|
s.characterName = name
|
||||||
|
s.characterID = cid
|
||||||
|
s.saveToken()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) refreshForToken(ctx context.Context, t *ESIToken) (*ESIToken, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "refresh_token")
|
||||||
|
form.Set("refresh_token", t.RefreshToken)
|
||||||
|
form.Set("client_id", s.clientID)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b))
|
||||||
|
}
|
||||||
|
var tr tokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.AccessToken = tr.AccessToken
|
||||||
|
if tr.RefreshToken != "" {
|
||||||
|
t.RefreshToken = tr.RefreshToken
|
||||||
|
}
|
||||||
|
if tr.ExpiresIn > 0 {
|
||||||
|
t.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
|
||||||
|
}
|
||||||
|
if s.db != nil {
|
||||||
|
_ = s.db.Save(t).Error
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) ensureAccessToken(ctx context.Context) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.accessToken == "" && s.db != nil && s.characterID != 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.loadToken()
|
||||||
|
s.mu.Lock()
|
||||||
|
}
|
||||||
|
tok := s.accessToken
|
||||||
|
exp := s.expiresAt
|
||||||
|
s.mu.Unlock()
|
||||||
|
if tok == "" {
|
||||||
|
return "", errors.New("not logged in")
|
||||||
|
}
|
||||||
|
if time.Now().After(exp.Add(-60 * time.Second)) {
|
||||||
|
if err := s.refresh(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
tok = s.accessToken
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) ensureAccessTokenFor(ctx context.Context, t *ESIToken) (string, error) {
|
||||||
|
if t.AccessToken == "" || time.Now().After(t.ExpiresAt.Add(-60*time.Second)) {
|
||||||
|
nt, err := s.refreshForToken(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return nt.AccessToken, nil
|
||||||
|
}
|
||||||
|
return t.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginning bool) error {
|
||||||
|
tok, err := s.ensureAccessToken(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) postWaypointWithToken(tok string, destinationID int64, clearOthers bool, addToBeginning bool) error {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("destination_id", strconv.FormatInt(destinationID, 10))
|
||||||
|
q.Set("add_to_beginning", strconv.FormatBool(addToBeginning))
|
||||||
|
q.Set("clear_other_waypoints", strconv.FormatBool(clearOthers))
|
||||||
|
q.Set("datasource", "tranquility")
|
||||||
|
endpoint := esiBase + "/v2/ui/autopilot/waypoint?" + q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-User-Agent", "signalerr/1.0")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) PostWaypointForAll(destinationID int64, clearOthers bool, addToBeginning bool) error {
|
||||||
|
if s.db == nil {
|
||||||
|
return errors.New("db not initialised")
|
||||||
|
}
|
||||||
|
var tokens []ESIToken
|
||||||
|
if err := s.db.Find(&tokens).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
for i := range tokens {
|
||||||
|
if !tokens[i].WaypointEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
tok, err := s.ensureAccessTokenFor(ctx, &tokens[i])
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleCharacterWaypointEnabled toggles the waypoint enabled status for a character
|
||||||
|
func (s *ESISSO) ToggleCharacterWaypointEnabled(characterID int64) error {
|
||||||
|
if s.db == nil {
|
||||||
|
return errors.New("db not initialised")
|
||||||
|
}
|
||||||
|
var token ESIToken
|
||||||
|
if err := s.db.Where("character_id = ?", characterID).First(&token).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token.WaypointEnabled = !token.WaypointEnabled
|
||||||
|
return s.db.Save(&token).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ESISSO) Status() SSOStatus {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return SSOStatus{
|
||||||
|
LoggedIn: s.accessToken != "",
|
||||||
|
CharacterID: s.characterID,
|
||||||
|
CharacterName: s.characterName,
|
||||||
|
ExpiresAt: s.expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSOStatus struct {
|
||||||
|
LoggedIn bool
|
||||||
|
CharacterID int64
|
||||||
|
CharacterName string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePKCE() (verifier string, challenge string, err error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
h := sha256.Sum256([]byte(v))
|
||||||
|
c := base64.RawURLEncoding.EncodeToString(h[:])
|
||||||
|
return v, c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randString(n int) string {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
_, _ = rand.Read(buf)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTokenCharacter(jwt string) (name string, id int64) {
|
||||||
|
parts := strings.Split(jwt, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &m); err != nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
if v, ok := m["name"].(string); ok {
|
||||||
|
name = v
|
||||||
|
}
|
||||||
|
if v, ok := m["sub"].(string); ok {
|
||||||
|
if idx := strings.LastIndexByte(v, ':'); idx > -1 {
|
||||||
|
if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil {
|
||||||
|
id = idv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNameCache loads a lowercase name->id map from the local DB once
|
||||||
|
func (s *ESISSO) ensureNameCache() error {
|
||||||
|
var err error
|
||||||
|
s.nameCacheOnce.Do(func() {
|
||||||
|
cache := make(map[string]int64, 50000)
|
||||||
|
if s.db != nil {
|
||||||
|
var rows []SolarSystem
|
||||||
|
// Only select required columns
|
||||||
|
if e := s.db.Select("solarSystemID, solarSystemName").Find(&rows).Error; e != nil {
|
||||||
|
err = e
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
cache[strings.ToLower(r.SolarSystemName)] = r.SolarSystemID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.nameToID = cache
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSystemIDByName resolves using ONLY the local DB cache (case-insensitive)
|
||||||
|
func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) {
|
||||||
|
if err := s.ensureNameCache(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if id, ok := s.nameToID[key]; ok {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("system not found in local DB: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSystemIDsByNames returns IDs in the same order as names using ONLY the local DB cache
|
||||||
|
func (s *ESISSO) ResolveSystemIDsByNames(ctx context.Context, names []string) ([]int64, error) {
|
||||||
|
if err := s.ensureNameCache(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]int64, len(names))
|
||||||
|
missing := []string{}
|
||||||
|
for i, n := range names {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(n))
|
||||||
|
if id, ok := s.nameToID[key]; ok {
|
||||||
|
out[i] = id
|
||||||
|
} else {
|
||||||
|
missing = append(missing, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, fmt.Errorf("systems not found in local DB: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostRouteForAll clears route and posts vias then destination last
|
||||||
|
func (s *ESISSO) PostRouteForAll(destID int64, viaIDs []int64) error {
|
||||||
|
if s.db == nil {
|
||||||
|
return errors.New("db not initialised")
|
||||||
|
}
|
||||||
|
var tokens []ESIToken
|
||||||
|
if err := s.db.Find(&tokens).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Deduplicate by CharacterID and filter enabled characters
|
||||||
|
uniq := make(map[int64]ESIToken, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
if t.WaypointEnabled {
|
||||||
|
uniq[t.CharacterID] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uniqueTokens := make([]ESIToken, 0, len(uniq))
|
||||||
|
for _, t := range uniq {
|
||||||
|
uniqueTokens = append(uniqueTokens, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var firstErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
// Run per-character in parallel
|
||||||
|
for i := range uniqueTokens {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(t ESIToken) {
|
||||||
|
defer wg.Done()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
tok, err := s.ensureAccessTokenFor(ctx, &t)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Post sequence for this character
|
||||||
|
if len(viaIDs) > 0 {
|
||||||
|
if err := s.postWaypointWithToken(tok, viaIDs[0], true, false); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range viaIDs[1:] {
|
||||||
|
if err := s.postWaypointWithToken(tok, id, false, false); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.postWaypointWithToken(tok, destID, false, false); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.postWaypointWithToken(tok, destID, true, false); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(uniqueTokens[i])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return firstErr
|
||||||
|
}
|
@@ -1,20 +1,9 @@
|
|||||||
FROM oven/bun:1.0.25-slim as builder
|
FROM oven/bun:1.0.25-slim as builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
COPY dist dist
|
||||||
COPY package*.json ./
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY bun.lockb ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Copy source files
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
@@ -6,6 +6,8 @@ import { RegionPage } from "./pages/RegionPage";
|
|||||||
import { SystemView } from "./pages/SystemView";
|
import { SystemView } from "./pages/SystemView";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
import { SearchDialog } from "@/components/SearchDialog";
|
||||||
|
import { SignatureRules } from "./pages/SignatureRules";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -18,9 +20,11 @@ function App() {
|
|||||||
<Route path="/regions/:region" element={<RegionPage />} />
|
<Route path="/regions/:region" element={<RegionPage />} />
|
||||||
<Route path="/regions/:region/:system" element={<SystemView />} />
|
<Route path="/regions/:region/:system" element={<SystemView />} />
|
||||||
<Route path="/systems/:system" element={<SystemView />} />
|
<Route path="/systems/:system" element={<SystemView />} />
|
||||||
|
<Route path="/settings/signature-rules" element={<SignatureRules />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<SearchDialog />
|
||||||
</Router>
|
</Router>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { StartESILogin, ESILoggedIn, ListCharacters, ToggleCharacterWaypointEnabled } from 'wailsjs/go/main/App';
|
||||||
|
import { main } from 'wailsjs/go/models';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -20,10 +24,49 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
|
export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [chars, setChars] = useState<main.CharacterInfo[]>([]);
|
||||||
|
|
||||||
|
const refreshState = async () => {
|
||||||
|
try {
|
||||||
|
const list = await ListCharacters();
|
||||||
|
setChars(list);
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
await StartESILogin();
|
||||||
|
toast({ title: 'EVE Login', description: 'Complete login in your browser.' });
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const ok = await ESILoggedIn();
|
||||||
|
if (ok) {
|
||||||
|
await refreshState();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Login failed', description: String(e), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCharacterClick = async (character: main.CharacterInfo) => {
|
||||||
|
try {
|
||||||
|
await ToggleCharacterWaypointEnabled(character.character_id);
|
||||||
|
await refreshState();
|
||||||
|
const newStatus = character.waypoint_enabled ? 'disabled' : 'enabled';
|
||||||
|
toast({ title: 'Waypoint Status', description: `${character.character_name} waypoints ${newStatus}` });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Toggle failed', description: String(e), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 py-4 px-4 border-b border-purple-500/20">
|
<div className="flex-shrink-0 py-4 px-4 border-b border-purple-500/20">
|
||||||
{/* Breadcrumb Navigation */}
|
|
||||||
{breadcrumbs.length > 0 && (
|
{breadcrumbs.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
@@ -52,8 +95,36 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Title */}
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">{title}</h1>
|
<h1 className="text-2xl font-bold text-white">{title}</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" variant="outline" className="border-purple-500/40 text-purple-200" onClick={() => navigate('/settings/signature-rules')}>Rules</Button>
|
||||||
|
{chars.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="grid gap-1 flex-1 justify-end"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${Math.ceil(chars.length / 2)}, 1fr)`,
|
||||||
|
gridTemplateRows: 'repeat(2, auto)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chars.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.character_id}
|
||||||
|
onClick={() => handleCharacterClick(c)}
|
||||||
|
className={`px-3 py-1 text-xs cursor-pointer transition-colors text-center overflow-hidden text-ellipsis ${c.waypoint_enabled
|
||||||
|
? 'bg-purple-500/20 text-purple-200 border border-purple-400/40 hover:bg-purple-500/30'
|
||||||
|
: 'bg-gray-500/20 text-gray-400 border border-gray-400/40 hover:bg-gray-500/30'
|
||||||
|
}`}
|
||||||
|
title={`Click to ${c.waypoint_enabled ? 'disable' : 'enable'} waypoints for ${c.character_name}`}
|
||||||
|
>
|
||||||
|
{c.character_name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button size="sm" className="bg-purple-600 hover:bg-purple-700" onClick={handleLogin}>Log in</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -15,6 +15,12 @@ interface MapNodeProps {
|
|||||||
security?: number;
|
security?: number;
|
||||||
signatures?: number;
|
signatures?: number;
|
||||||
isDraggable?: boolean;
|
isDraggable?: boolean;
|
||||||
|
disableNavigate?: boolean;
|
||||||
|
jumps?: number;
|
||||||
|
kills?: number;
|
||||||
|
showJumps?: boolean;
|
||||||
|
showKills?: boolean;
|
||||||
|
viewBoxWidth?: number; // Add viewBox width for scaling calculations
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MapNode: React.FC<MapNodeProps> = ({
|
export const MapNode: React.FC<MapNodeProps> = ({
|
||||||
@@ -30,7 +36,13 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
|||||||
type,
|
type,
|
||||||
security,
|
security,
|
||||||
signatures,
|
signatures,
|
||||||
isDraggable = false
|
isDraggable = false,
|
||||||
|
disableNavigate = false,
|
||||||
|
jumps,
|
||||||
|
kills,
|
||||||
|
showJumps = false,
|
||||||
|
showKills = false,
|
||||||
|
viewBoxWidth = 1200,
|
||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -65,14 +77,11 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
|||||||
onDragEnd?.(e);
|
onDragEnd?.(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeColor = security !== undefined
|
const nodeColor = security !== undefined ? getSecurityColor(security) : '#a855f7';
|
||||||
? getSecurityColor(security)
|
|
||||||
: '#a855f7'; // fallback purple color
|
|
||||||
|
|
||||||
if (type === 'region') {
|
if (type === 'region') {
|
||||||
// Further reduce region size to prevent overlap - made even smaller
|
const pillWidth = Math.max(name.length * 5, 40);
|
||||||
const pillWidth = Math.max(name.length * 5, 40); // Reduced from 8 to 5, min from 60 to 40
|
const pillHeight = 18;
|
||||||
const pillHeight = 18; // Reduced from 24 to 18
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -129,9 +138,8 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Render system as a dot with external label
|
|
||||||
const nodeSize = 6;
|
const nodeSize = 6;
|
||||||
const textOffset = 20; // Position text below the dot - moved down more
|
const textOffset = 20;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -179,7 +187,7 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
|||||||
className="transition-all duration-300"
|
className="transition-all duration-300"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Node label */}
|
{/* Node label - fixed visual size regardless of zoom */}
|
||||||
<text
|
<text
|
||||||
x="0"
|
x="0"
|
||||||
y={textOffset}
|
y={textOffset}
|
||||||
@@ -189,23 +197,96 @@ export const MapNode: React.FC<MapNodeProps> = ({
|
|||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
className={`transition-all duration-300 ${isHovered ? 'fill-purple-200' : 'fill-white'
|
className={`transition-all duration-300 ${isHovered ? 'fill-purple-200' : 'fill-white'
|
||||||
} pointer-events-none select-none`}
|
} pointer-events-none select-none`}
|
||||||
style={{ textShadow: '2px 2px 4px rgba(0,0,0,0.8)' }}
|
style={{
|
||||||
|
textShadow: '2px 2px 4px rgba(0,0,0,0.8)',
|
||||||
|
vectorEffect: 'non-scaling-stroke'
|
||||||
|
}}
|
||||||
|
transform={`scale(${1 / (1200 / viewBoxWidth)})`}
|
||||||
|
transformOrigin="0 0"
|
||||||
>
|
>
|
||||||
{name} {security !== undefined && (
|
{name} {security !== undefined && (
|
||||||
<tspan fill={getSecurityColor(security)}>{security.toFixed(1)}</tspan>
|
<tspan fill={getSecurityColor(security)}>{security.toFixed(1)}</tspan>
|
||||||
)}
|
)}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
{/* Dynamic text positioning based on what's shown */}
|
||||||
|
{(() => {
|
||||||
|
let currentY = textOffset + 15;
|
||||||
|
const textElements = [];
|
||||||
|
|
||||||
|
// Add signatures if present
|
||||||
|
if (signatures !== undefined && signatures > 0) {
|
||||||
|
textElements.push(
|
||||||
<text
|
<text
|
||||||
|
key="signatures"
|
||||||
x="0"
|
x="0"
|
||||||
y={textOffset + 15}
|
y={currentY}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill="#a3a3a3"
|
fill="#a3a3a3"
|
||||||
fontSize="12"
|
fontSize="12"
|
||||||
className="pointer-events-none select-none"
|
className="pointer-events-none select-none"
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.8)' }}
|
style={{
|
||||||
|
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
|
||||||
|
vectorEffect: 'non-scaling-stroke'
|
||||||
|
}}
|
||||||
|
transform={`scale(${1 / (1200 / viewBoxWidth)})`}
|
||||||
|
transformOrigin="0 0"
|
||||||
>
|
>
|
||||||
{signatures !== undefined && signatures > 0 && `📡 ${signatures}`}
|
📡 {signatures}
|
||||||
</text>
|
</text>
|
||||||
|
);
|
||||||
|
currentY += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add jumps if enabled and present
|
||||||
|
if (showJumps && jumps !== undefined) {
|
||||||
|
textElements.push(
|
||||||
|
<text
|
||||||
|
key="jumps"
|
||||||
|
x="0"
|
||||||
|
y={currentY}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#60a5fa"
|
||||||
|
fontSize="10"
|
||||||
|
className="pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
|
||||||
|
vectorEffect: 'non-scaling-stroke'
|
||||||
|
}}
|
||||||
|
transform={`scale(${1 / (1200 / viewBoxWidth)})`}
|
||||||
|
transformOrigin="0 0"
|
||||||
|
>
|
||||||
|
🚀 {jumps}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
currentY += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add kills if enabled and present
|
||||||
|
if (showKills && kills !== undefined) {
|
||||||
|
textElements.push(
|
||||||
|
<text
|
||||||
|
key="kills"
|
||||||
|
x="0"
|
||||||
|
y={currentY}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#f87171"
|
||||||
|
fontSize="10"
|
||||||
|
className="pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
|
||||||
|
vectorEffect: 'non-scaling-stroke'
|
||||||
|
}}
|
||||||
|
transform={`scale(${1 / (1200 / viewBoxWidth)})`}
|
||||||
|
transformOrigin="0 0"
|
||||||
|
>
|
||||||
|
⚔️ {kills}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return textElements;
|
||||||
|
})()}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { MapNode } from '@/components/MapNode';
|
import { MapNode } from '@/components/MapNode';
|
||||||
import { SystemContextMenu } from '@/components/SystemContextMenu';
|
import { SystemContextMenu } from '@/components/SystemContextMenu';
|
||||||
@@ -8,6 +8,25 @@ import { loadWormholeSystems, saveWormholeSystem, deleteWormholeSystem } from '@
|
|||||||
import { System, Position, Connection as ConnectionType } from '@/lib/types';
|
import { System, Position, Connection as ConnectionType } from '@/lib/types';
|
||||||
import { getSecurityColor } from '@/utils/securityColors';
|
import { getSecurityColor } from '@/utils/securityColors';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
|
import { ListCharacters, StartESILogin, SetDestinationForAll, PostRouteForAllByNames, GetCharacterLocations } from 'wailsjs/go/main/App';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { getSystemsRegions } from '@/utils/systemApi';
|
||||||
|
import { useSystemJumps, useSystemKills, resolveSystemID } from '@/hooks/useSystemStatistics';
|
||||||
|
import { StatisticsToggle } from './StatisticsToggle';
|
||||||
|
|
||||||
|
// Interaction/indicator constants
|
||||||
|
const SELECT_HOLD_MS = 300;
|
||||||
|
const PAN_THRESHOLD_PX = 6;
|
||||||
|
const DRAG_SNAP_DISTANCE = 20;
|
||||||
|
const VIA_WAYPOINT_RING_RADIUS = 14;
|
||||||
|
const VIA_WAYPOINT_RING_COLOR = '#10b981';
|
||||||
|
const INDICATED_RING_RADIUS = 20;
|
||||||
|
const INDICATED_RING_COLOR = '#f59e0b';
|
||||||
|
const INDICATED_RING_ANIM_VALUES = '18;22;18';
|
||||||
|
const INDICATED_RING_ANIM_DUR = '1.2s';
|
||||||
|
const SHIFT_SELECT_STROKE_COLOR = '#60a5fa';
|
||||||
|
const SHIFT_SELECT_FILL_COLOR = 'rgba(96,165,250,0.12)';
|
||||||
|
const SHIFT_SELECT_STROKE_WIDTH = 2;
|
||||||
|
|
||||||
interface RegionMapProps {
|
interface RegionMapProps {
|
||||||
regionName: string;
|
regionName: string;
|
||||||
@@ -54,6 +73,26 @@ function computeNodeConnections(systems: Map<string, System>): Map<string, Conne
|
|||||||
return connections;
|
return connections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache of region -> Map(systemName -> System) from region JSONs
|
||||||
|
const regionSystemsCache: Map<string, Map<string, System>> = new Map();
|
||||||
|
// Cache of universe region centroids (regionName -> {x, y})
|
||||||
|
const universeRegionPosCache: Map<string, { x: number; y: number }> = new Map();
|
||||||
|
let universeLoaded = false;
|
||||||
|
const ensureUniversePositions = async () => {
|
||||||
|
if (universeLoaded) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/universe.json');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const regions: Array<{ regionName: string; x: string; y: string; security: number; connectsTo: string }> = await resp.json();
|
||||||
|
for (const r of regions) {
|
||||||
|
universeRegionPosCache.set(r.regionName, { x: parseInt(r.x, 10), y: parseInt(r.y, 10) });
|
||||||
|
}
|
||||||
|
universeLoaded = true;
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormholeRegion = false }: RegionMapProps) => {
|
export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormholeRegion = false }: RegionMapProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [viewBox, setViewBox] = useState({ x: 0, y: 0, width: 1200, height: 800 });
|
const [viewBox, setViewBox] = useState({ x: 0, y: 0, width: 1200, height: 800 });
|
||||||
@@ -67,24 +106,236 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
const [positions, setPositions] = useState<Record<string, Position>>({});
|
const [positions, setPositions] = useState<Record<string, Position>>({});
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
const { data: rsystems, isLoading, error } = useRegionData(regionName);
|
const [viaMode, setViaMode] = useState(false);
|
||||||
|
const [viaDest, setViaDest] = useState<string | null>(null);
|
||||||
|
const [viaQueue, setViaQueue] = useState<string[]>([]);
|
||||||
|
|
||||||
|
type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string };
|
||||||
|
const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]);
|
||||||
|
const [meanNeighborAngle, setMeanNeighborAngle] = useState<Record<string, number>>({});
|
||||||
|
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
|
||||||
|
const [focusUntil, setFocusUntil] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Statistics state - MUST default to false to avoid API spam!
|
||||||
|
const [showJumps, setShowJumps] = useState(false);
|
||||||
|
const [showKills, setShowKills] = useState(false);
|
||||||
|
|
||||||
|
// System ID cache for statistics lookup
|
||||||
|
const [systemIDCache, setSystemIDCache] = useState<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
|
||||||
|
// New: selection/aim state for left-click aimbot behavior
|
||||||
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
|
const [indicatedSystem, setIndicatedSystem] = useState<string | null>(null);
|
||||||
|
const selectTimerRef = useRef<number | null>(null);
|
||||||
|
const downClientPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const mouseButtonRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// New: shift-drag circle selection state (VIA mode)
|
||||||
|
const [shiftSelecting, setShiftSelecting] = useState(false);
|
||||||
|
const [shiftCenter, setShiftCenter] = useState<Position | null>(null);
|
||||||
|
const [shiftRadius, setShiftRadius] = useState<number>(0);
|
||||||
|
|
||||||
|
// Interaction state machine (lightweight)
|
||||||
|
type InteractionMode = 'idle' | 'holding' | 'panning' | 'selecting' | 'shiftSelecting';
|
||||||
|
const [mode, setMode] = useState<InteractionMode>('idle');
|
||||||
|
|
||||||
|
// When focusSystem changes, set an expiry 20s in the future
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && error == null && rsystems && rsystems.size > 0)
|
if (focusSystem) {
|
||||||
|
setFocusUntil(Date.now() + 20000);
|
||||||
|
}
|
||||||
|
}, [focusSystem]);
|
||||||
|
|
||||||
|
// Timer to clear focus after expiry
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusUntil) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (Date.now() > focusUntil) {
|
||||||
|
setFocusUntil(null);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [focusUntil]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = async (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && viaMode) {
|
||||||
|
try {
|
||||||
|
if (!(await ensureAnyLoggedIn())) return;
|
||||||
|
if (viaDest) {
|
||||||
|
await PostRouteForAllByNames(viaDest, viaQueue);
|
||||||
|
toast({ title: 'Route set', description: `${viaDest}${viaQueue.length ? ' via ' + viaQueue.join(', ') : ''}` });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: 'Failed to set route', description: String(err), variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setViaMode(false);
|
||||||
|
setViaDest(null);
|
||||||
|
setViaQueue([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [viaMode, viaDest, viaQueue]);
|
||||||
|
|
||||||
|
const { data: rsystems, isLoading, error } = useRegionData(regionName);
|
||||||
|
|
||||||
|
// Fetch statistics data - only when toggles are enabled
|
||||||
|
const { data: jumpsData } = useSystemJumps(showJumps);
|
||||||
|
const { data: killsData } = useSystemKills(showKills);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && error == null && rsystems && rsystems.size > 0) {
|
||||||
setSystems(rsystems);
|
setSystems(rsystems);
|
||||||
|
|
||||||
|
// Pre-resolve all system IDs for statistics lookup
|
||||||
|
const resolveSystemIDs = async () => {
|
||||||
|
const newCache = new Map<string, number>();
|
||||||
|
for (const systemName of rsystems.keys()) {
|
||||||
|
try {
|
||||||
|
const id = await resolveSystemID(systemName);
|
||||||
|
if (id) {
|
||||||
|
newCache.set(systemName, id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to resolve system ID for ${systemName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSystemIDCache(newCache);
|
||||||
|
};
|
||||||
|
|
||||||
|
resolveSystemIDs();
|
||||||
|
}
|
||||||
}, [rsystems, isLoading, error]);
|
}, [rsystems, isLoading, error]);
|
||||||
|
|
||||||
// Process connections when systems or nodePositions change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!systems || systems.size === 0) return;
|
if (!systems || systems.size === 0) return;
|
||||||
console.log("Computing node positions");
|
|
||||||
const positions = computeNodePositions(systems);
|
const positions = computeNodePositions(systems);
|
||||||
setPositions(positions);
|
setPositions(positions);
|
||||||
console.log("Computing node connections");
|
|
||||||
const connections = computeNodeConnections(systems);
|
const connections = computeNodeConnections(systems);
|
||||||
setConnections(connections);
|
setConnections(connections);
|
||||||
|
// Compute per-system mean outbound angle in screen coords (atan2(dy,dx)) to in-region neighbors
|
||||||
|
const angleMap: Record<string, number> = {};
|
||||||
|
systems.forEach((sys, name) => {
|
||||||
|
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
let sumSin = 0, sumCos = 0, count = 0;
|
||||||
|
for (const n of neighbors) {
|
||||||
|
const neighbor = systems.get(n);
|
||||||
|
if (!neighbor) continue;
|
||||||
|
const dx = neighbor.x - sys.x;
|
||||||
|
const dy = neighbor.y - sys.y; // y-down screen
|
||||||
|
const a = Math.atan2(dy, dx);
|
||||||
|
sumSin += Math.sin(a);
|
||||||
|
sumCos += Math.cos(a);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
angleMap[name] = Math.atan2(sumSin, sumCos); // average angle
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setMeanNeighborAngle(angleMap);
|
||||||
}, [systems]);
|
}, [systems]);
|
||||||
|
|
||||||
// Load wormhole systems on mount if in wormhole region
|
// Poll character locations every 7s and store those in this region
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: any;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const locs = await GetCharacterLocations();
|
||||||
|
const here = locs.filter(l => !!l.solar_system_name && systems.has(l.solar_system_name));
|
||||||
|
setCharLocs(here.map(l => ({ character_id: l.character_id, character_name: l.character_name, solar_system_name: l.solar_system_name })));
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
timer = setTimeout(tick, 7000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
return () => { if (timer) clearTimeout(timer); };
|
||||||
|
}, [systems]);
|
||||||
|
|
||||||
|
// Compute off-region indicators: dedupe per (from, toRegion), compute avg color, and angle via universe centroids
|
||||||
|
useEffect(() => {
|
||||||
|
const computeOffRegion = async () => {
|
||||||
|
if (!systems || systems.size === 0) { setOffRegionIndicators([]); return; }
|
||||||
|
|
||||||
|
const toLookup: Set<string> = new Set();
|
||||||
|
for (const [, sys] of systems.entries()) {
|
||||||
|
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
for (const n of neighbors) if (!systems.has(n)) toLookup.add(n);
|
||||||
|
}
|
||||||
|
if (toLookup.size === 0) { setOffRegionIndicators([]); return; }
|
||||||
|
|
||||||
|
const nameToRegion = await getSystemsRegions(Array.from(toLookup));
|
||||||
|
|
||||||
|
// Cache remote region systems (for security values) and universe positions
|
||||||
|
const neededRegions = new Set<string>();
|
||||||
|
for (const n of Object.keys(nameToRegion)) {
|
||||||
|
const r = nameToRegion[n];
|
||||||
|
if (!r) continue;
|
||||||
|
if (!regionSystemsCache.has(r)) neededRegions.add(r);
|
||||||
|
}
|
||||||
|
if (neededRegions.size > 0) {
|
||||||
|
await Promise.all(Array.from(neededRegions).map(async (r) => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/${encodeURIComponent(r)}.json`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const systemsList: System[] = await resp.json();
|
||||||
|
const m = new Map<string, System>();
|
||||||
|
systemsList.forEach(s => m.set(s.solarSystemName, s));
|
||||||
|
regionSystemsCache.set(r, m);
|
||||||
|
} catch (_) { /* noop */ }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await ensureUniversePositions();
|
||||||
|
|
||||||
|
// Build indicators: group by from+toRegion; angle from local geometry only (meanNeighborAngle + PI)
|
||||||
|
type Agg = { from: string; toRegion: string; count: number; sumRemoteSec: number; sampleTo?: string };
|
||||||
|
const grouped: Map<string, Agg> = new Map();
|
||||||
|
for (const [fromName, sys] of systems.entries()) {
|
||||||
|
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
for (const n of neighbors) {
|
||||||
|
if (systems.has(n)) continue;
|
||||||
|
const toRegion = nameToRegion[n];
|
||||||
|
if (!toRegion || toRegion === regionName) continue;
|
||||||
|
const remote = regionSystemsCache.get(toRegion)?.get(n);
|
||||||
|
const gkey = `${fromName}__${toRegion}`;
|
||||||
|
const agg = grouped.get(gkey) || { from: fromName, toRegion, count: 0, sumRemoteSec: 0, sampleTo: n };
|
||||||
|
agg.count += 1;
|
||||||
|
if (remote) agg.sumRemoteSec += (remote.security || 0);
|
||||||
|
grouped.set(gkey, agg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: OffIndicator[] = [];
|
||||||
|
for (const [, agg] of grouped) {
|
||||||
|
if (agg.count === 0) continue;
|
||||||
|
// Angle: point away from existing connections = meanNeighborAngle + PI
|
||||||
|
let angle = meanNeighborAngle[agg.from];
|
||||||
|
if (angle === undefined) {
|
||||||
|
// fallback: away from region centroid
|
||||||
|
// compute centroid of current region nodes
|
||||||
|
let cx = 0, cy = 0, c = 0;
|
||||||
|
systems.forEach(s => { cx += s.x; cy += s.y; c++; });
|
||||||
|
if (c > 0) { cx /= c; cy /= c; }
|
||||||
|
const sys = systems.get(agg.from)!;
|
||||||
|
angle = Math.atan2(sys.y - cy, sys.x - cx);
|
||||||
|
}
|
||||||
|
angle = angle + Math.PI;
|
||||||
|
|
||||||
|
// Color from avg of local system sec and avg remote sec; local from this system
|
||||||
|
const localSec = (systems.get(agg.from)?.security || 0);
|
||||||
|
const remoteAvg = agg.count > 0 ? (agg.sumRemoteSec / agg.count) : 0;
|
||||||
|
const color = getSecurityColor((localSec + remoteAvg) / 2);
|
||||||
|
out.push({ from: agg.from, toRegion: agg.toRegion, count: agg.count, color, angle, sampleTo: agg.sampleTo });
|
||||||
|
}
|
||||||
|
setOffRegionIndicators(out);
|
||||||
|
};
|
||||||
|
computeOffRegion();
|
||||||
|
}, [systems, regionName, meanNeighborAngle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWormholeRegion) {
|
if (isWormholeRegion) {
|
||||||
loadWormholeSystems().then(wormholeSystems => {
|
loadWormholeSystems().then(wormholeSystems => {
|
||||||
@@ -93,7 +344,36 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
}
|
}
|
||||||
}, [isWormholeRegion]);
|
}, [isWormholeRegion]);
|
||||||
|
|
||||||
const handleSystemClick = (systemName: string) => {
|
const ensureAnyLoggedIn = async () => {
|
||||||
|
try {
|
||||||
|
const list = await ListCharacters();
|
||||||
|
if (Array.isArray(list) && list.length > 0) return true;
|
||||||
|
await StartESILogin();
|
||||||
|
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.' });
|
||||||
|
return false;
|
||||||
|
} catch (e: any) {
|
||||||
|
await StartESILogin();
|
||||||
|
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.', variant: 'destructive' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSystemClick = async (systemName: string) => {
|
||||||
|
if (viaMode) {
|
||||||
|
setViaQueue(prev => {
|
||||||
|
// toggle behavior: add if missing, remove if present
|
||||||
|
if (prev.includes(systemName)) {
|
||||||
|
const next = prev.filter(n => n !== systemName);
|
||||||
|
toast({ title: 'Waypoint removed', description: systemName });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
const next = [...prev, systemName];
|
||||||
|
toast({ title: 'Waypoint queued', description: systemName });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
console.log('VIA waypoint toggle:', systemName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (focusSystem === systemName) return;
|
if (focusSystem === systemName) return;
|
||||||
navigate(`/regions/${regionName}/${systemName}`);
|
navigate(`/regions/${regionName}/${systemName}`);
|
||||||
};
|
};
|
||||||
@@ -171,7 +451,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
const dx = system.x - x;
|
const dx = system.x - x;
|
||||||
const dy = system.y - y;
|
const dy = system.y - y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
if (distance < 20) {
|
if (distance < DRAG_SNAP_DISTANCE) {
|
||||||
targetSystem = system;
|
targetSystem = system;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -227,40 +507,311 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
setTempConnection(null);
|
setTempConnection(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
// Helper: convert client to SVG coords
|
||||||
if (!svgRef.current) return;
|
const clientToSvg = (clientX: number, clientY: number) => {
|
||||||
setIsPanning(true);
|
if (!svgRef.current) return { x: 0, y: 0 };
|
||||||
const rect = svgRef.current.getBoundingClientRect();
|
const pt = svgRef.current.createSVGPoint();
|
||||||
setLastPanPoint({
|
pt.x = clientX;
|
||||||
x: e.clientX - rect.left,
|
pt.y = clientY;
|
||||||
y: e.clientY - rect.top
|
const svgPoint = pt.matrixTransform(svgRef.current.getScreenCTM()!.inverse());
|
||||||
});
|
return { x: svgPoint.x, y: svgPoint.y };
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
||||||
if (!isPanning || !svgRef.current) return;
|
|
||||||
|
|
||||||
const rect = svgRef.current.getBoundingClientRect();
|
|
||||||
const currentPoint = {
|
|
||||||
x: e.clientX - rect.left,
|
|
||||||
y: e.clientY - rect.top
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper: find nearest system name to SVG point
|
||||||
|
const findNearestSystem = (svgX: number, svgY: number): string | null => {
|
||||||
|
if (systems.size === 0) return null;
|
||||||
|
let nearestName: string | null = null;
|
||||||
|
let nearestDist2 = Number.POSITIVE_INFINITY;
|
||||||
|
systems.forEach((_sys, name) => {
|
||||||
|
const pos = positions[name];
|
||||||
|
if (!pos) return;
|
||||||
|
const dx = pos.x - svgX;
|
||||||
|
const dy = pos.y - svgY;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < nearestDist2) {
|
||||||
|
nearestDist2 = d2;
|
||||||
|
nearestName = name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nearestName;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create lookup maps for system statistics
|
||||||
|
const jumpsBySystemID = useMemo(() => {
|
||||||
|
if (!jumpsData) return new Map();
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
jumpsData.forEach(jump => {
|
||||||
|
map.set(jump.system_id, jump.ship_jumps);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [jumpsData]);
|
||||||
|
|
||||||
|
const killsBySystemID = useMemo(() => {
|
||||||
|
if (!killsData) return new Map();
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
killsData.forEach(kill => {
|
||||||
|
map.set(kill.system_id, kill.ship_kills);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [killsData]);
|
||||||
|
|
||||||
|
// Helper functions to get statistics for a system
|
||||||
|
const getSystemJumps = (systemName: string): number | undefined => {
|
||||||
|
if (!showJumps) return undefined;
|
||||||
|
|
||||||
|
const systemID = systemIDCache.get(systemName);
|
||||||
|
if (!systemID) return undefined;
|
||||||
|
|
||||||
|
const jumps = jumpsBySystemID.get(systemID);
|
||||||
|
if (!jumps || jumps === 0) return undefined;
|
||||||
|
|
||||||
|
console.log(`🚀 Found ${jumps} jumps for ${systemName} (ID: ${systemID})`);
|
||||||
|
return jumps;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSystemKills = (systemName: string): number | undefined => {
|
||||||
|
if (!showKills) return undefined;
|
||||||
|
|
||||||
|
const systemID = systemIDCache.get(systemName);
|
||||||
|
if (!systemID) return undefined;
|
||||||
|
|
||||||
|
const kills = killsBySystemID.get(systemID);
|
||||||
|
if (!kills || kills === 0) return undefined;
|
||||||
|
|
||||||
|
console.log(`⚔️ Found ${kills} kills for ${systemName} (ID: ${systemID})`);
|
||||||
|
return kills;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Commit shift selection: toggle all systems within radius
|
||||||
|
const commitShiftSelection = useCallback(() => {
|
||||||
|
if (!shiftCenter || shiftRadius <= 0) return;
|
||||||
|
const within: string[] = [];
|
||||||
|
Object.keys(positions).forEach(name => {
|
||||||
|
const pos = positions[name];
|
||||||
|
if (!pos) return;
|
||||||
|
const dx = pos.x - shiftCenter.x;
|
||||||
|
const dy = pos.y - shiftCenter.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist <= shiftRadius) within.push(name);
|
||||||
|
});
|
||||||
|
if (within.length === 0) return;
|
||||||
|
setViaQueue(prev => {
|
||||||
|
const prevSet = new Set(prev);
|
||||||
|
const toToggle = new Set(within);
|
||||||
|
// remove toggled ones that were present
|
||||||
|
const kept = prev.filter(n => !toToggle.has(n));
|
||||||
|
// add new ones (those within but not previously present), preserve within order
|
||||||
|
const additions = within.filter(n => !prevSet.has(n));
|
||||||
|
const next = kept.concat(additions);
|
||||||
|
toast({ title: 'VIA toggled', description: `${within.length} systems` });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [positions, shiftCenter, shiftRadius]);
|
||||||
|
|
||||||
|
const clearSelectTimer = () => {
|
||||||
|
if (selectTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(selectTimerRef.current);
|
||||||
|
selectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// const PAN_THRESHOLD_PX = 6; // movement before starting pan
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
|
||||||
|
// If context menu is open, left-click closes it and no selection should happen
|
||||||
|
if (contextMenu) {
|
||||||
|
if (e.button === 0) setContextMenu(null);
|
||||||
|
clearSelectTimer();
|
||||||
|
setIsSelecting(false);
|
||||||
|
setIsPanning(false);
|
||||||
|
setShiftSelecting(false);
|
||||||
|
setMode('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseButtonRef.current = e.button;
|
||||||
|
|
||||||
|
// SHIFT + VIA mode: start circle selection (left button only)
|
||||||
|
if (viaMode && e.shiftKey && e.button === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||||||
|
setShiftSelecting(true);
|
||||||
|
setShiftCenter(svgPt);
|
||||||
|
setShiftRadius(0);
|
||||||
|
setMode('shiftSelecting');
|
||||||
|
// cancel any hold-to-select/pan intents
|
||||||
|
setIsSelecting(false);
|
||||||
|
setIsPanning(false);
|
||||||
|
clearSelectTimer();
|
||||||
|
downClientPointRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only left button initiates selection/panning
|
||||||
|
if (e.button !== 0) {
|
||||||
|
clearSelectTimer();
|
||||||
|
setIsSelecting(false);
|
||||||
|
setMode('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// record down point (client) and seed pan origin
|
||||||
|
const rect = svgRef.current.getBoundingClientRect();
|
||||||
|
setLastPanPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
downClientPointRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
// initial indicate nearest system under cursor
|
||||||
|
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||||||
|
const near = findNearestSystem(svgPt.x, svgPt.y);
|
||||||
|
setIndicatedSystem(near);
|
||||||
|
|
||||||
|
// start delayed select mode timer
|
||||||
|
setIsSelecting(false);
|
||||||
|
setMode('holding');
|
||||||
|
clearSelectTimer();
|
||||||
|
selectTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsSelecting(true);
|
||||||
|
setMode('selecting');
|
||||||
|
}, SELECT_HOLD_MS);
|
||||||
|
}, [positions, systems, viaMode, contextMenu]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
// if dragging node, delegate
|
||||||
|
if (draggingNode) { handleSvgMouseMove(e); return; }
|
||||||
|
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
|
||||||
|
// Shift selection radius update
|
||||||
|
if (shiftSelecting && shiftCenter) {
|
||||||
|
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||||||
|
const dx = svgPt.x - shiftCenter.x;
|
||||||
|
const dy = svgPt.y - shiftCenter.y;
|
||||||
|
setShiftRadius(Math.sqrt(dx * dx + dy * dy));
|
||||||
|
setMode('shiftSelecting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = svgRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (isPanning) {
|
||||||
|
const currentPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
const deltaX = (lastPanPoint.x - currentPoint.x) * (viewBox.width / rect.width);
|
const deltaX = (lastPanPoint.x - currentPoint.x) * (viewBox.width / rect.width);
|
||||||
const deltaY = (lastPanPoint.y - currentPoint.y) * (viewBox.height / rect.height);
|
const deltaY = (lastPanPoint.y - currentPoint.y) * (viewBox.height / rect.height);
|
||||||
|
setViewBox(prev => ({ ...prev, x: prev.x + deltaX, y: prev.y + deltaY }));
|
||||||
setViewBox(prev => ({
|
|
||||||
...prev,
|
|
||||||
x: prev.x + deltaX,
|
|
||||||
y: prev.y + deltaY
|
|
||||||
}));
|
|
||||||
|
|
||||||
setLastPanPoint(currentPoint);
|
setLastPanPoint(currentPoint);
|
||||||
}, [isPanning, lastPanPoint, viewBox.width, viewBox.height]);
|
setMode('panning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
// determine if we should start panning (from holding)
|
||||||
|
const down = downClientPointRef.current;
|
||||||
|
if (down && !isSelecting) {
|
||||||
|
const dx = e.clientX - down.x;
|
||||||
|
const dy = e.clientY - down.y;
|
||||||
|
const dist2 = dx * dx + dy * dy;
|
||||||
|
if (dist2 > PAN_THRESHOLD_PX * PAN_THRESHOLD_PX) {
|
||||||
|
// user intends to pan; cancel selection
|
||||||
|
clearSelectTimer();
|
||||||
|
setIsSelecting(false);
|
||||||
|
setIndicatedSystem(null);
|
||||||
|
setIsPanning(true);
|
||||||
|
setMode('panning');
|
||||||
|
// seed pan origin with current
|
||||||
|
const currentPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
|
setLastPanPoint(currentPoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selection mode: update indicated nearest system as cursor moves
|
||||||
|
if (isSelecting) {
|
||||||
|
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||||||
|
const near = findNearestSystem(svgPt.x, svgPt.y);
|
||||||
|
setIndicatedSystem(near);
|
||||||
|
setMode('selecting');
|
||||||
|
}
|
||||||
|
}, [draggingNode, isPanning, lastPanPoint, viewBox.width, viewBox.height, isSelecting, positions, systems, shiftSelecting, shiftCenter]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
|
||||||
|
// if dragging node, delegate
|
||||||
|
if (draggingNode) { if (e) handleSvgMouseUp(e); return; }
|
||||||
|
|
||||||
|
// If context menu open, left click should just close it; do not select
|
||||||
|
if (contextMenu && mouseButtonRef.current === 0) {
|
||||||
|
setContextMenu(null);
|
||||||
|
clearSelectTimer();
|
||||||
setIsPanning(false);
|
setIsPanning(false);
|
||||||
}, []);
|
setIsSelecting(false);
|
||||||
|
setIndicatedSystem(null);
|
||||||
|
setShiftSelecting(false);
|
||||||
|
setShiftCenter(null);
|
||||||
|
setShiftRadius(0);
|
||||||
|
downClientPointRef.current = null;
|
||||||
|
mouseButtonRef.current = null;
|
||||||
|
setMode('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit shift selection if active (only if left button initiated)
|
||||||
|
if (shiftSelecting) {
|
||||||
|
if (mouseButtonRef.current === 0) {
|
||||||
|
commitShiftSelection();
|
||||||
|
}
|
||||||
|
setShiftSelecting(false);
|
||||||
|
setShiftCenter(null);
|
||||||
|
setShiftRadius(0);
|
||||||
|
clearSelectTimer();
|
||||||
|
setIsPanning(false);
|
||||||
|
setIsSelecting(false);
|
||||||
|
setIndicatedSystem(null);
|
||||||
|
downClientPointRef.current = null;
|
||||||
|
mouseButtonRef.current = null;
|
||||||
|
setMode('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore non-left button for selection commit
|
||||||
|
if (mouseButtonRef.current !== 0) {
|
||||||
|
clearSelectTimer();
|
||||||
|
setIsPanning(false);
|
||||||
|
setIsSelecting(false);
|
||||||
|
setIndicatedSystem(null);
|
||||||
|
downClientPointRef.current = null;
|
||||||
|
mouseButtonRef.current = null;
|
||||||
|
setMode('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelectTimer();
|
||||||
|
|
||||||
|
if (isPanning) {
|
||||||
|
setIsPanning(false);
|
||||||
|
mouseButtonRef.current = null;
|
||||||
|
setMode('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// commit selection if any
|
||||||
|
let target = indicatedSystem;
|
||||||
|
if (!target && e && svgRef.current) {
|
||||||
|
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||||||
|
target = findNearestSystem(svgPt.x, svgPt.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
handleSystemClick(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset selection state
|
||||||
|
setIsSelecting(false);
|
||||||
|
setIndicatedSystem(null);
|
||||||
|
downClientPointRef.current = null;
|
||||||
|
mouseButtonRef.current = null;
|
||||||
|
setMode('idle');
|
||||||
|
}, [draggingNode, isPanning, indicatedSystem, positions, systems, shiftSelecting, commitShiftSelection, contextMenu]);
|
||||||
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -288,8 +839,46 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
});
|
});
|
||||||
}, [viewBox]);
|
}, [viewBox]);
|
||||||
|
|
||||||
|
const handleBackgroundContextMenu = (e: React.MouseEvent) => {
|
||||||
|
if (!svgRef.current || systems.size === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Convert click to SVG coordinates
|
||||||
|
const pt = svgRef.current.createSVGPoint();
|
||||||
|
pt.x = e.clientX;
|
||||||
|
pt.y = e.clientY;
|
||||||
|
const svgPoint = pt.matrixTransform(svgRef.current.getScreenCTM()!.inverse());
|
||||||
|
const clickX = svgPoint.x;
|
||||||
|
const clickY = svgPoint.y;
|
||||||
|
|
||||||
|
// Find nearest system by Euclidean distance in SVG space
|
||||||
|
let nearestName: string | null = null;
|
||||||
|
let nearestDist2 = Number.POSITIVE_INFINITY;
|
||||||
|
systems.forEach((sys, name) => {
|
||||||
|
const pos = positions[name];
|
||||||
|
if (!pos) return;
|
||||||
|
const dx = pos.x - clickX;
|
||||||
|
const dy = pos.y - clickY;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < nearestDist2) {
|
||||||
|
nearestDist2 = d2;
|
||||||
|
nearestName = name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nearestName) {
|
||||||
|
const sys = systems.get(nearestName)!;
|
||||||
|
// Place the menu at the system's on-screen position
|
||||||
|
const pt2 = svgRef.current.createSVGPoint();
|
||||||
|
pt2.x = positions[nearestName]!.x;
|
||||||
|
pt2.y = positions[nearestName]!.y;
|
||||||
|
const screenPoint = pt2.matrixTransform(svgRef.current.getScreenCTM()!);
|
||||||
|
setContextMenu({ x: screenPoint.x, y: screenPoint.y, system: sys });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, system: System) => {
|
const handleContextMenu = (e: React.MouseEvent, system: System) => {
|
||||||
if (!isWormholeRegion) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
@@ -364,6 +953,25 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSetDestination = async (systemName: string, wantVia: boolean) => {
|
||||||
|
try {
|
||||||
|
if (!(await ensureAnyLoggedIn())) return;
|
||||||
|
if (wantVia) {
|
||||||
|
setViaDest(systemName);
|
||||||
|
setViaQueue([]);
|
||||||
|
setViaMode(true);
|
||||||
|
console.log('Via mode start, dest:', systemName);
|
||||||
|
toast({ title: 'Via mode', description: `Destination ${systemName}. Click systems to add waypoints. Esc to commit.` });
|
||||||
|
} else {
|
||||||
|
await SetDestinationForAll(systemName, true, false);
|
||||||
|
toast({ title: 'Destination set', description: systemName });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to set destination:', e);
|
||||||
|
toast({ title: 'Failed to set destination', description: String(e), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Close context menu when clicking outside
|
// Close context menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = () => setContextMenu(null);
|
const handleClickOutside = () => setContextMenu(null);
|
||||||
@@ -371,6 +979,27 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
return () => document.removeEventListener('click', handleClickOutside);
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onWindowMouseUp = () => {
|
||||||
|
// if shift selection ongoing, commit on global mouseup as well
|
||||||
|
if (shiftSelecting && mouseButtonRef.current === 0) {
|
||||||
|
commitShiftSelection();
|
||||||
|
}
|
||||||
|
clearSelectTimer();
|
||||||
|
setIsPanning(false);
|
||||||
|
setIsSelecting(false);
|
||||||
|
setIndicatedSystem(null);
|
||||||
|
setShiftSelecting(false);
|
||||||
|
setShiftCenter(null);
|
||||||
|
setShiftRadius(0);
|
||||||
|
downClientPointRef.current = null;
|
||||||
|
mouseButtonRef.current = null;
|
||||||
|
setMode('idle');
|
||||||
|
};
|
||||||
|
window.addEventListener('mouseup', onWindowMouseUp);
|
||||||
|
return () => window.removeEventListener('mouseup', onWindowMouseUp);
|
||||||
|
}, [shiftSelecting, commitShiftSelection]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
<div className="h-full w-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||||
@@ -404,25 +1033,14 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
|
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
|
||||||
className="cursor-grab active:cursor-grabbing"
|
className={`${(mode === 'selecting' || mode === 'shiftSelecting') ? 'cursor-crosshair' : (mode === 'panning' ? 'cursor-grabbing' : 'cursor-grab')}`}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={(e) => {
|
onMouseMove={handleMouseMove}
|
||||||
if (isPanning) {
|
onMouseUp={(e) => handleMouseUp(e)}
|
||||||
handleMouseMove(e);
|
|
||||||
} else {
|
|
||||||
handleSvgMouseMove(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseUp={(e) => {
|
|
||||||
if (isPanning) {
|
|
||||||
handleMouseUp();
|
|
||||||
} else {
|
|
||||||
handleSvgMouseUp(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onDoubleClick={handleMapDoubleClick}
|
onDoubleClick={handleMapDoubleClick}
|
||||||
|
onContextMenu={handleBackgroundContextMenu}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="glow">
|
<filter id="glow">
|
||||||
@@ -432,6 +1050,9 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
<feMergeNode in="SourceGraphic" />
|
<feMergeNode in="SourceGraphic" />
|
||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
|
<marker id="arrowhead" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||||
|
<path d="M0,0 L0,6 L6,3 z" fill="#ffffff" />
|
||||||
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Render all connections */}
|
{/* Render all connections */}
|
||||||
@@ -453,6 +1074,20 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Shift selection circle (VIA mode) */}
|
||||||
|
{shiftSelecting && shiftCenter && (
|
||||||
|
<g style={{ pointerEvents: 'none' }}>
|
||||||
|
<circle
|
||||||
|
cx={shiftCenter.x}
|
||||||
|
cy={shiftCenter.y}
|
||||||
|
r={Math.max(shiftRadius, 0)}
|
||||||
|
fill={SHIFT_SELECT_FILL_COLOR}
|
||||||
|
stroke={SHIFT_SELECT_STROKE_COLOR}
|
||||||
|
strokeWidth={SHIFT_SELECT_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Render existing systems */}
|
{/* Render existing systems */}
|
||||||
{Array.from(systems.entries()).map(([key, system]) => (
|
{Array.from(systems.entries()).map(([key, system]) => (
|
||||||
<MapNode
|
<MapNode
|
||||||
@@ -460,7 +1095,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
id={system.solarSystemName}
|
id={system.solarSystemName}
|
||||||
name={system.solarSystemName}
|
name={system.solarSystemName}
|
||||||
position={positions[system.solarSystemName] || { x: 0, y: 0 }}
|
position={positions[system.solarSystemName] || { x: 0, y: 0 }}
|
||||||
onClick={() => handleSystemClick(system.solarSystemName)}
|
onClick={() => { /* handled at svg-level aimbot commit */ }}
|
||||||
onDoubleClick={(e) => handleSystemDoubleClick(e, positions[system.solarSystemName])}
|
onDoubleClick={(e) => handleSystemDoubleClick(e, positions[system.solarSystemName])}
|
||||||
onDragStart={(e) => handleNodeDragStart(e, system.solarSystemName)}
|
onDragStart={(e) => handleNodeDragStart(e, system.solarSystemName)}
|
||||||
onDrag={handleSvgMouseMove}
|
onDrag={handleSvgMouseMove}
|
||||||
@@ -470,34 +1105,133 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
security={system.security}
|
security={system.security}
|
||||||
signatures={system.signatures}
|
signatures={system.signatures}
|
||||||
isDraggable={isWormholeRegion}
|
isDraggable={isWormholeRegion}
|
||||||
|
disableNavigate={viaMode}
|
||||||
|
jumps={getSystemJumps(system.solarSystemName)}
|
||||||
|
kills={getSystemKills(system.solarSystemName)}
|
||||||
|
showJumps={showJumps}
|
||||||
|
showKills={showKills}
|
||||||
|
viewBoxWidth={viewBox.width}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* VIA waypoints indicator rings */}
|
||||||
|
{viaMode && viaQueue.map((name) => (
|
||||||
|
positions[name] ? (
|
||||||
|
<g key={`via-${name}`} style={{ pointerEvents: 'none' }}>
|
||||||
|
<circle
|
||||||
|
cx={positions[name].x}
|
||||||
|
cy={positions[name].y}
|
||||||
|
r={VIA_WAYPOINT_RING_RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={VIA_WAYPOINT_RING_COLOR}
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="0.9"
|
||||||
|
filter="url(#glow)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
) : null
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Indicated (aim) system ring */}
|
||||||
|
{indicatedSystem && positions[indicatedSystem] && (
|
||||||
|
<g style={{ pointerEvents: 'none' }}>
|
||||||
|
<circle
|
||||||
|
cx={positions[indicatedSystem].x}
|
||||||
|
cy={positions[indicatedSystem].y}
|
||||||
|
r={INDICATED_RING_RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={INDICATED_RING_COLOR}
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="0.9"
|
||||||
|
filter="url(#glow)"
|
||||||
|
>
|
||||||
|
<animate attributeName="r" values={INDICATED_RING_ANIM_VALUES} dur={INDICATED_RING_ANIM_DUR} repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Character location markers */}
|
||||||
|
{charLocs.map((c, idx) => {
|
||||||
|
const pos = positions[c.solar_system_name];
|
||||||
|
if (!pos) return null;
|
||||||
|
const yoff = -18 - (idx % 3) * 10; // stagger small vertical offsets if multiple in same system
|
||||||
|
return (
|
||||||
|
<g key={`char-${c.character_id}-${idx}`} transform={`translate(${pos.x}, ${pos.y + yoff})`}>
|
||||||
|
<rect x={-2} y={-9} width={Math.max(c.character_name.length * 5, 24)} height={14} rx={3} fill="#0f172a" opacity={0.9} stroke="#00d1ff" strokeWidth={1} />
|
||||||
|
<text x={Math.max(c.character_name.length * 5, 24) / 2 - 2} y={2} textAnchor="middle" fontSize={8} fill="#ffffff">{c.character_name}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Off-region indicators: labeled arrows pointing toward the destination region */}
|
||||||
|
{offRegionIndicators.map((ind, idx) => {
|
||||||
|
const pos = positions[ind.from];
|
||||||
|
if (!pos) return null;
|
||||||
|
const len = 26;
|
||||||
|
const r0 = 10; // start just outside node
|
||||||
|
const dx = Math.cos(ind.angle);
|
||||||
|
const dy = Math.sin(ind.angle);
|
||||||
|
const x1 = pos.x + dx * r0;
|
||||||
|
const y1 = pos.y + dy * r0;
|
||||||
|
const x2 = x1 + dx * len;
|
||||||
|
const y2 = y1 + dy * len;
|
||||||
|
const labelX = x2 + dx * 8;
|
||||||
|
const labelY = y2 + dy * 8;
|
||||||
|
const label = ind.count > 1 ? `${ind.toRegion} ×${ind.count}` : ind.toRegion;
|
||||||
|
return (
|
||||||
|
<g key={`offr-${idx}`}>
|
||||||
|
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={ind.color} strokeWidth={2} markerEnd="url(#arrowhead)">
|
||||||
|
<title>{label}</title>
|
||||||
|
</line>
|
||||||
|
<g transform={`translate(${labelX}, ${labelY})`} onClick={(e) => { e.stopPropagation(); navigate(`/regions/${encodeURIComponent(ind.toRegion)}`); }}>
|
||||||
|
<rect x={-2} y={-10} width={Math.max(label.length * 5, 24)} height={14} rx={7} fill="#0f172a" opacity={0.85} stroke={ind.color} strokeWidth={1} />
|
||||||
|
<text x={Math.max(label.length * 5, 24) / 2 - 2} y={0} textAnchor="middle" fontSize="8" fill="#ffffff">{label}</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Highlight focused system */}
|
{/* Highlight focused system */}
|
||||||
{focusSystem && positions[focusSystem] && (
|
{focusSystem && focusUntil && Date.now() <= focusUntil && positions[focusSystem] && (
|
||||||
|
<g style={{ pointerEvents: 'none' }}>
|
||||||
<circle
|
<circle
|
||||||
cx={positions[focusSystem].x}
|
cx={positions[focusSystem].x}
|
||||||
cy={positions[focusSystem].y}
|
cy={positions[focusSystem].y}
|
||||||
r="15"
|
r="20"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#a855f7"
|
stroke="#a855f7"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeDasharray="5,5"
|
opacity="0.9"
|
||||||
opacity="0.8"
|
filter="url(#glow)"
|
||||||
>
|
>
|
||||||
<animateTransform
|
<animate
|
||||||
attributeName="transform"
|
attributeName="r"
|
||||||
attributeType="XML"
|
values="18;22;18"
|
||||||
type="rotate"
|
dur="1.5s"
|
||||||
from={`0 ${positions[focusSystem].x} ${positions[focusSystem].y}`}
|
|
||||||
to={`360 ${positions[focusSystem].x} ${positions[focusSystem].y}`}
|
|
||||||
dur="10s"
|
|
||||||
repeatCount="indefinite"
|
repeatCount="indefinite"
|
||||||
/>
|
/>
|
||||||
</circle>
|
</circle>
|
||||||
|
<text x={positions[focusSystem].x + 12} y={positions[focusSystem].y - 10} fontSize="10" fill="#ffffff" stroke="#0f172a" strokeWidth="2" paintOrder="stroke">{focusSystem}</text>
|
||||||
|
</g>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{viaMode && (
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 rounded bg-emerald-600 text-white text-xs shadow">
|
||||||
|
VIA mode: Dest {viaDest} ({viaQueue.length} waypoints). Esc to commit
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statistics Toggle - positioned to avoid overlaps */}
|
||||||
|
<div className="absolute bottom-4 left-4">
|
||||||
|
<StatisticsToggle
|
||||||
|
jumpsEnabled={showJumps}
|
||||||
|
killsEnabled={showKills}
|
||||||
|
onJumpsToggle={setShowJumps}
|
||||||
|
onKillsToggle={setShowKills}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<SystemContextMenu
|
<SystemContextMenu
|
||||||
@@ -507,6 +1241,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
onRename={(newName) => handleRenameSystem(contextMenu.system.solarSystemName, newName)}
|
onRename={(newName) => handleRenameSystem(contextMenu.system.solarSystemName, newName)}
|
||||||
onDelete={handleDeleteSystem}
|
onDelete={handleDeleteSystem}
|
||||||
onClearConnections={handleClearConnections}
|
onClearConnections={handleClearConnections}
|
||||||
|
onSetDestination={(systemName, via) => onSetDestination(systemName, via)}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
151
frontend/src/components/SearchDialog.tsx
Normal file
151
frontend/src/components/SearchDialog.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { AhoCorasick } from '@/lib/aho';
|
||||||
|
import { ListSystemsWithRegions, SetDestinationForAll, ListCharacters, StartESILogin } from 'wailsjs/go/main/App';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
system: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllSystems(): Promise<Array<SearchResult>> {
|
||||||
|
const list = await ListSystemsWithRegions();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Array<SearchResult> = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const system = String(item.system);
|
||||||
|
if (seen.has(system)) continue;
|
||||||
|
seen.add(system);
|
||||||
|
out.push({ system, region: String(item.region) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchDialog: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [all, setAll] = useState<Array<SearchResult>>([]);
|
||||||
|
const [results, setResults] = useState<Array<SearchResult>>([]);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const automaton = useMemo(() => {
|
||||||
|
const ac = new AhoCorasick();
|
||||||
|
if (query.trim().length > 0) ac.add(query.toLowerCase());
|
||||||
|
ac.build();
|
||||||
|
return ac;
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const isCtrlF = (e.key === 'f' || e.key === 'F') && (e.ctrlKey || e.metaKey);
|
||||||
|
if (isCtrlF) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (all.length === 0) {
|
||||||
|
loadAllSystems().then(setAll);
|
||||||
|
}
|
||||||
|
}, [open, all.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length === 0) { setResults([]); return; }
|
||||||
|
const scored: Array<{ s: SearchResult; idx: number }> = [];
|
||||||
|
for (const r of all) {
|
||||||
|
const idx = automaton.searchFirstIndex(r.system.toLowerCase());
|
||||||
|
if (idx >= 0) scored.push({ s: r, idx });
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => {
|
||||||
|
if (a.idx !== b.idx) return a.idx - b.idx; // earlier index first
|
||||||
|
if (a.s.system.length !== b.s.system.length) return a.s.system.length - b.s.system.length; // shorter name next
|
||||||
|
return a.s.system.localeCompare(b.s.system);
|
||||||
|
});
|
||||||
|
setResults(scored.slice(0, 10).map(x => x.s));
|
||||||
|
}, [query, all, automaton]);
|
||||||
|
|
||||||
|
const onSelect = (r: SearchResult) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
navigate(`/regions/${encodeURIComponent(r.region)}?focus=${encodeURIComponent(r.system)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureAnyLoggedIn = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const list = await ListCharacters();
|
||||||
|
if (Array.isArray(list) && list.length > 0) return true;
|
||||||
|
await StartESILogin();
|
||||||
|
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.' });
|
||||||
|
return false;
|
||||||
|
} catch (e: any) {
|
||||||
|
await StartESILogin();
|
||||||
|
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.', variant: 'destructive' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultClick = async (e: React.MouseEvent, r: SearchResult) => {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (!(await ensureAnyLoggedIn())) return;
|
||||||
|
await SetDestinationForAll(r.system, true, false);
|
||||||
|
toast({ title: 'Destination set', description: r.system });
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: 'Failed to set destination', description: String(err), variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelect(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg bg-slate-900/95 border border-purple-500/40 text-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Search systems</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Type system name..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-400"
|
||||||
|
/>
|
||||||
|
<div className="max-h-80 overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
|
||||||
|
{results.length === 0 && query && (
|
||||||
|
<div className="p-3 text-sm text-slate-300">No results</div>
|
||||||
|
)}
|
||||||
|
{results.map((r, idx) => (
|
||||||
|
<button
|
||||||
|
key={`${r.region}-${r.system}-${idx}`}
|
||||||
|
className="w-full text-left p-3 hover:bg-purple-500/20"
|
||||||
|
onClick={(e) => handleResultClick(e, r)}
|
||||||
|
title="Click to open, Shift+Click to set destination"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{r.system}</div>
|
||||||
|
<div className="text-xs text-slate-300">{r.region}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
@@ -74,6 +74,15 @@ export const SignatureCard = ({ signature, onDelete, onUpdate }: SignatureCardPr
|
|||||||
<h3 className="text-white font-medium text-lg">
|
<h3 className="text-white font-medium text-lg">
|
||||||
{signature.signame || 'Unnamed Signature'}
|
{signature.signame || 'Unnamed Signature'}
|
||||||
</h3>
|
</h3>
|
||||||
|
{signature.note && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1 justify-center">
|
||||||
|
{signature.note.split(';').filter(Boolean).map((note, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="bg-blue-900/50 text-blue-200 border-blue-500 px-3 py-1 text-sm font-semibold">
|
||||||
|
{note.trim()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional Info */}
|
{/* Additional Info */}
|
||||||
|
@@ -30,7 +30,8 @@ export const SignatureEditModal = ({ signature, isOpen, onClose, onSave }: Signa
|
|||||||
signame: signature.signame || "",
|
signame: signature.signame || "",
|
||||||
dangerous: signature.dangerous || false,
|
dangerous: signature.dangerous || false,
|
||||||
scanned: signature.scanned || "",
|
scanned: signature.scanned || "",
|
||||||
identifier: signature.identifier || ""
|
identifier: signature.identifier || "",
|
||||||
|
note: signature.note || ""
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ export const SignatureEditModal = ({ signature, isOpen, onClose, onSave }: Signa
|
|||||||
type: formData.type === "unknown" ? undefined : formData.type,
|
type: formData.type === "unknown" ? undefined : formData.type,
|
||||||
signame: formData.signame || undefined,
|
signame: formData.signame || undefined,
|
||||||
dangerous: formData.dangerous,
|
dangerous: formData.dangerous,
|
||||||
scanned: formData.scanned || undefined
|
scanned: formData.scanned || undefined,
|
||||||
|
note: formData.note || undefined
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
toast({
|
toast({
|
||||||
@@ -71,7 +73,8 @@ export const SignatureEditModal = ({ signature, isOpen, onClose, onSave }: Signa
|
|||||||
signame: signature.signame || "",
|
signame: signature.signame || "",
|
||||||
dangerous: signature.dangerous || false,
|
dangerous: signature.dangerous || false,
|
||||||
scanned: signature.scanned || "",
|
scanned: signature.scanned || "",
|
||||||
identifier: signature.identifier || ""
|
identifier: signature.identifier || "",
|
||||||
|
note: signature.note || ""
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -152,6 +155,18 @@ export const SignatureEditModal = ({ signature, isOpen, onClose, onSave }: Signa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Note Field - Add before the Dangerous Flag */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="note" className="text-slate-200">Important Note</Label>
|
||||||
|
<Input
|
||||||
|
id="note"
|
||||||
|
value={formData.note}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, note: e.target.value }))}
|
||||||
|
placeholder="Add important information about this signature"
|
||||||
|
className="bg-slate-700 border-slate-600 text-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dangerous Flag */}
|
{/* Dangerous Flag */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="dangerous" className="text-slate-200">Dangerous</Label>
|
<Label htmlFor="dangerous" className="text-slate-200">Dangerous</Label>
|
||||||
|
@@ -88,8 +88,7 @@ export const SignatureListItem = ({ signature, onDelete, onUpdate }: SignatureLi
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between p-4 border-b border-slate-700 hover:bg-slate-800/40 transition-colors cursor-pointer ${
|
className={`flex items-center justify-between p-4 border-b border-slate-700 hover:bg-slate-800/40 transition-colors cursor-pointer ${oldEntry ? "opacity-50" : ""
|
||||||
oldEntry ? "opacity-50" : ""
|
|
||||||
} ${isGasSite ? 'bg-emerald-900/40 border-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)] hover:shadow-[0_0_20px_rgba(16,185,129,0.7)]' : ''}`}
|
} ${isGasSite ? 'bg-emerald-900/40 border-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)] hover:shadow-[0_0_20px_rgba(16,185,129,0.7)]' : ''}`}
|
||||||
onClick={() => setIsEditModalOpen(true)}
|
onClick={() => setIsEditModalOpen(true)}
|
||||||
>
|
>
|
||||||
@@ -117,6 +116,15 @@ export const SignatureListItem = ({ signature, onDelete, onUpdate }: SignatureLi
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
{signature.note && (
|
||||||
|
<div className="flex flex-wrap gap-1 ml-2">
|
||||||
|
{signature.note.split(';').filter(Boolean).map((note, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="bg-blue-900/50 text-blue-200 border-blue-500 px-2 py-0.5 text-sm font-semibold">
|
||||||
|
{note.trim()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
44
frontend/src/components/StatisticsToggle.tsx
Normal file
44
frontend/src/components/StatisticsToggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface StatisticsToggleProps {
|
||||||
|
jumpsEnabled: boolean;
|
||||||
|
killsEnabled: boolean;
|
||||||
|
onJumpsToggle: (enabled: boolean) => void;
|
||||||
|
onKillsToggle: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatisticsToggle: React.FC<StatisticsToggleProps> = ({
|
||||||
|
jumpsEnabled,
|
||||||
|
killsEnabled,
|
||||||
|
onJumpsToggle,
|
||||||
|
onKillsToggle,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/90 backdrop-blur-sm rounded-lg p-2 shadow-lg border border-slate-700">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onJumpsToggle(!jumpsEnabled)}
|
||||||
|
className={`p-2 rounded transition-colors ${
|
||||||
|
jumpsEnabled
|
||||||
|
? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30'
|
||||||
|
: 'bg-gray-600/20 text-gray-400 hover:bg-gray-600/30'
|
||||||
|
}`}
|
||||||
|
title={jumpsEnabled ? 'Hide Jumps' : 'Show Jumps'}
|
||||||
|
>
|
||||||
|
🚀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onKillsToggle(!killsEnabled)}
|
||||||
|
className={`p-2 rounded transition-colors ${
|
||||||
|
killsEnabled
|
||||||
|
? 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
|
||||||
|
: 'bg-gray-600/20 text-gray-400 hover:bg-gray-600/30'
|
||||||
|
}`}
|
||||||
|
title={killsEnabled ? 'Hide Kills' : 'Show Kills'}
|
||||||
|
>
|
||||||
|
⚔️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -8,10 +8,11 @@ interface SystemContextMenuProps {
|
|||||||
onRename: (newName: string) => void;
|
onRename: (newName: string) => void;
|
||||||
onDelete: (system: System) => void;
|
onDelete: (system: System) => void;
|
||||||
onClearConnections: (system: System) => void;
|
onClearConnections: (system: System) => void;
|
||||||
|
onSetDestination?: (systemName: string, viaMode: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearConnections, onClose }: SystemContextMenuProps) => {
|
export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearConnections, onSetDestination, onClose }: SystemContextMenuProps) => {
|
||||||
if (!system) {
|
if (!system) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,16 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon
|
|||||||
setIsRenaming(false);
|
setIsRenaming(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetDestinationClick = (e: React.MouseEvent) => {
|
||||||
|
const via = !!e.shiftKey;
|
||||||
|
if (typeof onSetDestination === 'function') {
|
||||||
|
onSetDestination(system.solarSystemName, via);
|
||||||
|
} else {
|
||||||
|
console.error('onSetDestination not provided');
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
@@ -82,6 +93,14 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
<div className="h-px bg-slate-700 my-1" />
|
||||||
|
<button
|
||||||
|
onClick={handleSetDestinationClick}
|
||||||
|
className="w-full px-3 py-1 text-left text-emerald-400 hover:bg-slate-700 rounded text-sm"
|
||||||
|
title="Shift-click to enter via mode and append waypoints"
|
||||||
|
>
|
||||||
|
Set destination
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -128,4 +128,3 @@ export {
|
|||||||
Sheet, SheetClose,
|
Sheet, SheetClose,
|
||||||
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
|
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
41
frontend/src/hooks/useSystemStatistics.ts
Normal file
41
frontend/src/hooks/useSystemStatistics.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import * as App from 'wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// Helper function to resolve system name to ID
|
||||||
|
export const resolveSystemID = async (systemName: string): Promise<number | null> => {
|
||||||
|
try {
|
||||||
|
const id = await App.ResolveSystemIDByName(systemName);
|
||||||
|
return id;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to resolve system ID for ${systemName}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSystemJumps = (enabled: boolean = false) => {
|
||||||
|
console.log('useSystemJumps called with enabled:', enabled);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['systemJumps'],
|
||||||
|
queryFn: () => {
|
||||||
|
console.log('🚀 FETCHING SYSTEM JUMPS DATA - API REQUEST MADE!');
|
||||||
|
return App.GetSystemJumps();
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchInterval: enabled ? 5 * 60 * 1000 : false, // Only refetch when enabled
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSystemKills = (enabled: boolean = false) => {
|
||||||
|
console.log('useSystemKills called with enabled:', enabled);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['systemKills'],
|
||||||
|
queryFn: () => {
|
||||||
|
console.log('⚔️ FETCHING SYSTEM KILLS DATA - API REQUEST MADE!');
|
||||||
|
return App.GetSystemKills();
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchInterval: enabled ? 5 * 60 * 1000 : false, // Only refetch when enabled
|
||||||
|
});
|
||||||
|
};
|
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -66,7 +64,9 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
html, body {
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -74,9 +74,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
80
frontend/src/lib/aho.ts
Normal file
80
frontend/src/lib/aho.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export class AhoCorasick {
|
||||||
|
private goto: Array<Map<string, number>> = [new Map()];
|
||||||
|
private out: Array<boolean> = [false];
|
||||||
|
private outLen: Array<number> = [0];
|
||||||
|
private fail: Array<number> = [0];
|
||||||
|
|
||||||
|
add(pattern: string) {
|
||||||
|
let state = 0;
|
||||||
|
for (const ch of pattern) {
|
||||||
|
const next = this.goto[state].get(ch);
|
||||||
|
if (next === undefined) {
|
||||||
|
const newState = this.goto.length;
|
||||||
|
this.goto[state].set(ch, newState);
|
||||||
|
this.goto.push(new Map());
|
||||||
|
this.out.push(false);
|
||||||
|
this.outLen.push(0);
|
||||||
|
this.fail.push(0);
|
||||||
|
state = newState;
|
||||||
|
} else {
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.out[state] = true;
|
||||||
|
this.outLen[state] = Math.max(this.outLen[state], pattern.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
const queue: number[] = [];
|
||||||
|
for (const [, s] of this.goto[0]) {
|
||||||
|
this.fail[s] = 0;
|
||||||
|
queue.push(s);
|
||||||
|
}
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const r = queue.shift()!;
|
||||||
|
for (const [a, s] of this.goto[r]) {
|
||||||
|
queue.push(s);
|
||||||
|
let state = this.fail[r];
|
||||||
|
while (state !== 0 && !this.goto[state].has(a)) {
|
||||||
|
state = this.fail[state];
|
||||||
|
}
|
||||||
|
const f = this.goto[state].get(a) ?? 0;
|
||||||
|
this.fail[s] = f;
|
||||||
|
this.out[s] = this.out[s] || this.out[f];
|
||||||
|
if (this.outLen[f] > this.outLen[s]) this.outLen[s] = this.outLen[f];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if any pattern is found in text
|
||||||
|
searchHas(text: string): boolean {
|
||||||
|
let state = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
while (state !== 0 && !this.goto[state].has(ch)) {
|
||||||
|
state = this.fail[state];
|
||||||
|
}
|
||||||
|
state = this.goto[state].get(ch) ?? 0;
|
||||||
|
if (this.out[state]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the starting index of the first match in text, or -1 if none
|
||||||
|
searchFirstIndex(text: string): number {
|
||||||
|
let state = 0;
|
||||||
|
let i = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
while (state !== 0 && !this.goto[state].has(ch)) {
|
||||||
|
state = this.fail[state];
|
||||||
|
}
|
||||||
|
state = this.goto[state].get(ch) ?? 0;
|
||||||
|
if (this.out[state]) {
|
||||||
|
const len = this.outLen[state] || 0;
|
||||||
|
if (len > 0) return i - len + 1;
|
||||||
|
return i; // fallback
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
@@ -11,8 +11,13 @@ export enum Collections {
|
|||||||
Mfas = "_mfas",
|
Mfas = "_mfas",
|
||||||
Otps = "_otps",
|
Otps = "_otps",
|
||||||
Superusers = "_superusers",
|
Superusers = "_superusers",
|
||||||
|
IndBillitem = "ind_billItem",
|
||||||
|
IndChar = "ind_char",
|
||||||
|
IndJob = "ind_job",
|
||||||
|
IndTransaction = "ind_transaction",
|
||||||
Regionview = "regionview",
|
Regionview = "regionview",
|
||||||
Signature = "signature",
|
Signature = "signature",
|
||||||
|
SignatureNoteRules = "signature_note_rules",
|
||||||
Sigview = "sigview",
|
Sigview = "sigview",
|
||||||
System = "system",
|
System = "system",
|
||||||
WormholeSystems = "wormholeSystems",
|
WormholeSystems = "wormholeSystems",
|
||||||
@@ -94,6 +99,74 @@ export type SuperusersRecord = {
|
|||||||
verified?: boolean
|
verified?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IndBillitemRecord = {
|
||||||
|
created?: IsoDateString
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
updated?: IsoDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IndCharRecord = {
|
||||||
|
created?: IsoDateString
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
updated?: IsoDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum IndJobStatusOptions {
|
||||||
|
"Planned" = "Planned",
|
||||||
|
"Acquisition" = "Acquisition",
|
||||||
|
"Running" = "Running",
|
||||||
|
"Done" = "Done",
|
||||||
|
"Selling" = "Selling",
|
||||||
|
"Closed" = "Closed",
|
||||||
|
"Tracked" = "Tracked",
|
||||||
|
"Staging" = "Staging",
|
||||||
|
"Inbound" = "Inbound",
|
||||||
|
"Outbound" = "Outbound",
|
||||||
|
"Delivered" = "Delivered",
|
||||||
|
"Queued" = "Queued",
|
||||||
|
}
|
||||||
|
export type IndJobRecord = {
|
||||||
|
billOfMaterials?: RecordIdString[]
|
||||||
|
character?: RecordIdString
|
||||||
|
consumedMaterials?: RecordIdString[]
|
||||||
|
created?: IsoDateString
|
||||||
|
expenditures?: RecordIdString[]
|
||||||
|
id: string
|
||||||
|
income?: RecordIdString[]
|
||||||
|
jobEnd?: IsoDateString
|
||||||
|
jobStart?: IsoDateString
|
||||||
|
outputItem: string
|
||||||
|
outputQuantity: number
|
||||||
|
parallel?: number
|
||||||
|
produced?: number
|
||||||
|
projectedCost?: number
|
||||||
|
projectedRevenue?: number
|
||||||
|
runtime?: number
|
||||||
|
saleEnd?: IsoDateString
|
||||||
|
saleStart?: IsoDateString
|
||||||
|
status: IndJobStatusOptions
|
||||||
|
updated?: IsoDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IndTransactionRecord = {
|
||||||
|
buyer?: string
|
||||||
|
corporation?: string
|
||||||
|
created?: IsoDateString
|
||||||
|
date: IsoDateString
|
||||||
|
id: string
|
||||||
|
itemName: string
|
||||||
|
job?: RecordIdString
|
||||||
|
location?: string
|
||||||
|
quantity: number
|
||||||
|
totalPrice: number
|
||||||
|
unitPrice: number
|
||||||
|
updated?: IsoDateString
|
||||||
|
wallet?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type RegionviewRecord = {
|
export type RegionviewRecord = {
|
||||||
id: string
|
id: string
|
||||||
sigcount?: number
|
sigcount?: number
|
||||||
@@ -107,17 +180,28 @@ export type SignatureRecord = {
|
|||||||
id: string
|
id: string
|
||||||
identifier: string
|
identifier: string
|
||||||
name?: string
|
name?: string
|
||||||
|
note?: string
|
||||||
scanned?: string
|
scanned?: string
|
||||||
system: RecordIdString
|
system: RecordIdString
|
||||||
type?: string
|
type?: string
|
||||||
updated?: IsoDateString
|
updated?: IsoDateString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SignatureNoteRulesRecord = {
|
||||||
|
created?: IsoDateString
|
||||||
|
enabled?: boolean
|
||||||
|
id: string
|
||||||
|
note: string
|
||||||
|
regex: string
|
||||||
|
updated?: IsoDateString
|
||||||
|
}
|
||||||
|
|
||||||
export type SigviewRecord = {
|
export type SigviewRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
dangerous?: boolean
|
dangerous?: boolean
|
||||||
id: string
|
id: string
|
||||||
identifier: string
|
identifier: string
|
||||||
|
note?: string
|
||||||
scanned?: string
|
scanned?: string
|
||||||
signame?: string
|
signame?: string
|
||||||
sysid?: RecordIdString
|
sysid?: RecordIdString
|
||||||
@@ -151,8 +235,13 @@ export type ExternalauthsResponse<Texpand = unknown> = Required<ExternalauthsRec
|
|||||||
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
|
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
|
||||||
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
|
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
|
||||||
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
|
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
|
||||||
|
export type IndBillitemResponse<Texpand = unknown> = Required<IndBillitemRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type IndCharResponse<Texpand = unknown> = Required<IndCharRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type IndJobResponse<Texpand = unknown> = Required<IndJobRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type IndTransactionResponse<Texpand = unknown> = Required<IndTransactionRecord> & BaseSystemFields<Texpand>
|
||||||
export type RegionviewResponse<Texpand = unknown> = Required<RegionviewRecord> & BaseSystemFields<Texpand>
|
export type RegionviewResponse<Texpand = unknown> = Required<RegionviewRecord> & BaseSystemFields<Texpand>
|
||||||
export type SignatureResponse<Texpand = unknown> = Required<SignatureRecord> & BaseSystemFields<Texpand>
|
export type SignatureResponse<Texpand = unknown> = Required<SignatureRecord> & BaseSystemFields<Texpand>
|
||||||
|
export type SignatureNoteRulesResponse<Texpand = unknown> = Required<SignatureNoteRulesRecord> & BaseSystemFields<Texpand>
|
||||||
export type SigviewResponse<Texpand = unknown> = Required<SigviewRecord> & BaseSystemFields<Texpand>
|
export type SigviewResponse<Texpand = unknown> = Required<SigviewRecord> & BaseSystemFields<Texpand>
|
||||||
export type SystemResponse<Texpand = unknown> = Required<SystemRecord> & BaseSystemFields<Texpand>
|
export type SystemResponse<Texpand = unknown> = Required<SystemRecord> & BaseSystemFields<Texpand>
|
||||||
export type WormholeSystemsResponse<Texpand = unknown> = Required<WormholeSystemsRecord> & BaseSystemFields<Texpand>
|
export type WormholeSystemsResponse<Texpand = unknown> = Required<WormholeSystemsRecord> & BaseSystemFields<Texpand>
|
||||||
@@ -165,8 +254,13 @@ export type CollectionRecords = {
|
|||||||
_mfas: MfasRecord
|
_mfas: MfasRecord
|
||||||
_otps: OtpsRecord
|
_otps: OtpsRecord
|
||||||
_superusers: SuperusersRecord
|
_superusers: SuperusersRecord
|
||||||
|
ind_billItem: IndBillitemRecord
|
||||||
|
ind_char: IndCharRecord
|
||||||
|
ind_job: IndJobRecord
|
||||||
|
ind_transaction: IndTransactionRecord
|
||||||
regionview: RegionviewRecord
|
regionview: RegionviewRecord
|
||||||
signature: SignatureRecord
|
signature: SignatureRecord
|
||||||
|
signature_note_rules: SignatureNoteRulesRecord
|
||||||
sigview: SigviewRecord
|
sigview: SigviewRecord
|
||||||
system: SystemRecord
|
system: SystemRecord
|
||||||
wormholeSystems: WormholeSystemsRecord
|
wormholeSystems: WormholeSystemsRecord
|
||||||
@@ -178,8 +272,13 @@ export type CollectionResponses = {
|
|||||||
_mfas: MfasResponse
|
_mfas: MfasResponse
|
||||||
_otps: OtpsResponse
|
_otps: OtpsResponse
|
||||||
_superusers: SuperusersResponse
|
_superusers: SuperusersResponse
|
||||||
|
ind_billItem: IndBillitemResponse
|
||||||
|
ind_char: IndCharResponse
|
||||||
|
ind_job: IndJobResponse
|
||||||
|
ind_transaction: IndTransactionResponse
|
||||||
regionview: RegionviewResponse
|
regionview: RegionviewResponse
|
||||||
signature: SignatureResponse
|
signature: SignatureResponse
|
||||||
|
signature_note_rules: SignatureNoteRulesResponse
|
||||||
sigview: SigviewResponse
|
sigview: SigviewResponse
|
||||||
system: SystemResponse
|
system: SystemResponse
|
||||||
wormholeSystems: WormholeSystemsResponse
|
wormholeSystems: WormholeSystemsResponse
|
||||||
@@ -194,8 +293,13 @@ export type TypedPocketBase = PocketBase & {
|
|||||||
collection(idOrName: '_mfas'): RecordService<MfasResponse>
|
collection(idOrName: '_mfas'): RecordService<MfasResponse>
|
||||||
collection(idOrName: '_otps'): RecordService<OtpsResponse>
|
collection(idOrName: '_otps'): RecordService<OtpsResponse>
|
||||||
collection(idOrName: '_superusers'): RecordService<SuperusersResponse>
|
collection(idOrName: '_superusers'): RecordService<SuperusersResponse>
|
||||||
|
collection(idOrName: 'ind_billItem'): RecordService<IndBillitemResponse>
|
||||||
|
collection(idOrName: 'ind_char'): RecordService<IndCharResponse>
|
||||||
|
collection(idOrName: 'ind_job'): RecordService<IndJobResponse>
|
||||||
|
collection(idOrName: 'ind_transaction'): RecordService<IndTransactionResponse>
|
||||||
collection(idOrName: 'regionview'): RecordService<RegionviewResponse>
|
collection(idOrName: 'regionview'): RecordService<RegionviewResponse>
|
||||||
collection(idOrName: 'signature'): RecordService<SignatureResponse>
|
collection(idOrName: 'signature'): RecordService<SignatureResponse>
|
||||||
|
collection(idOrName: 'signature_note_rules'): RecordService<SignatureNoteRulesResponse>
|
||||||
collection(idOrName: 'sigview'): RecordService<SigviewResponse>
|
collection(idOrName: 'sigview'): RecordService<SigviewResponse>
|
||||||
collection(idOrName: 'system'): RecordService<SystemResponse>
|
collection(idOrName: 'system'): RecordService<SystemResponse>
|
||||||
collection(idOrName: 'wormholeSystems'): RecordService<WormholeSystemsResponse>
|
collection(idOrName: 'wormholeSystems'): RecordService<WormholeSystemsResponse>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { RegionMap } from '@/components/RegionMap';
|
import { RegionMap } from '@/components/RegionMap';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
@@ -6,6 +6,8 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
export const RegionPage = () => {
|
export const RegionPage = () => {
|
||||||
const { region } = useParams<{ region: string }>();
|
const { region } = useParams<{ region: string }>();
|
||||||
|
const [sp] = useSearchParams();
|
||||||
|
const focus = sp.get('focus') || undefined;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (!region) {
|
if (!region) {
|
||||||
@@ -31,6 +33,7 @@ export const RegionPage = () => {
|
|||||||
<RegionMap
|
<RegionMap
|
||||||
regionName={region}
|
regionName={region}
|
||||||
isWormholeRegion={region === "Wormhole"}
|
isWormholeRegion={region === "Wormhole"}
|
||||||
|
focusSystem={focus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
124
frontend/src/pages/SignatureRules.tsx
Normal file
124
frontend/src/pages/SignatureRules.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import pb from '@/lib/pocketbase';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { SignatureNoteRulesResponse, Collections } from '@/lib/pbtypes';
|
||||||
|
|
||||||
|
export const SignatureRules = () => {
|
||||||
|
const [rules, setRules] = useState<SignatureNoteRulesResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState({ regex: '', note: '' });
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(Collections.SignatureNoteRules).getFullList<SignatureNoteRulesResponse>({ batch: 1000, sort: '-updated' });
|
||||||
|
setRules(list);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Load failed', description: String(e), variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!creating.regex.trim() || !creating.note.trim()) return;
|
||||||
|
try {
|
||||||
|
await pb.collection(Collections.SignatureNoteRules).create({ regex: creating.regex.trim(), note: creating.note.trim(), enabled: true });
|
||||||
|
setCreating({ regex: '', note: '' });
|
||||||
|
await load();
|
||||||
|
toast({ title: 'Rule added', description: 'New rule created.' });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Create failed', description: String(e), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (id: string, patch: Partial<SignatureNoteRulesResponse>) => {
|
||||||
|
try {
|
||||||
|
await pb.collection(Collections.SignatureNoteRules).update(id, patch);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Update failed', description: String(e), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await pb.collection(Collections.SignatureNoteRules).delete(id);
|
||||||
|
await load();
|
||||||
|
toast({ title: 'Rule deleted' });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Delete failed', description: String(e), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 overflow-hidden">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Header title="Signature Rules" breadcrumbs={[{ label: 'Universe', path: '/' }, { label: 'Signature Rules' }]} />
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||||
|
<div className="bg-black/20 border border-purple-500/30 rounded p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Regex (e.g. ^Angel.*Outpost$|Guristas.*)"
|
||||||
|
value={creating.regex}
|
||||||
|
onChange={e => setCreating({ ...creating, regex: e.target.value })}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Input placeholder="Note/Tag (e.g. 3/10)" value={creating.note} onChange={e => setCreating({ ...creating, note: e.target.value })} />
|
||||||
|
<Button onClick={handleCreate} disabled={loading}>Add Rule</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-black/20 border border-purple-500/30 rounded">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-slate-300">Enabled</TableHead>
|
||||||
|
<TableHead className="text-slate-300">Regex</TableHead>
|
||||||
|
<TableHead className="text-slate-300">Note</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map(r => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Switch checked={!!r.enabled} onCheckedChange={(v) => handleUpdate(r.id, { enabled: v })} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-0">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={r.regex}
|
||||||
|
onChange={e => setRules(prev => prev.map(x => x.id === r.id ? { ...x, regex: e.target.value } : x))}
|
||||||
|
onBlur={e => handleUpdate(r.id, { regex: e.currentTarget.value })}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-0">
|
||||||
|
<Input
|
||||||
|
value={r.note}
|
||||||
|
onChange={e => setRules(prev => prev.map(x => x.id === r.id ? { ...x, note: e.target.value } : x))}
|
||||||
|
onBlur={e => handleUpdate(r.id, { note: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="destructive" onClick={() => handleDelete(r.id)}>Delete</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -8,7 +8,7 @@ import { Header } from "@/components/Header";
|
|||||||
import { parseSignature, parseScannedPercentage } from "@/utils/signatureParser";
|
import { parseSignature, parseScannedPercentage } from "@/utils/signatureParser";
|
||||||
import { getSystemId } from "@/utils/systemApi";
|
import { getSystemId } from "@/utils/systemApi";
|
||||||
import pb from "@/lib/pocketbase";
|
import pb from "@/lib/pocketbase";
|
||||||
import { SigviewRecord as Signature, SignatureRecord } from "@/lib/pbtypes";
|
import { SigviewRecord as Signature, SignatureRecord, SignatureNoteRulesResponse, Collections } from "@/lib/pbtypes";
|
||||||
|
|
||||||
export const SystemView = () => {
|
export const SystemView = () => {
|
||||||
const { system, region } = useParams();
|
const { system, region } = useParams();
|
||||||
@@ -22,6 +22,7 @@ export const SystemView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveSignature = async (signature: Signature): Promise<void> => {
|
const saveSignature = async (signature: Signature): Promise<void> => {
|
||||||
|
console.log(signature);
|
||||||
try {
|
try {
|
||||||
// Check if signature already exists
|
// Check if signature already exists
|
||||||
let existingRecord: SignatureRecord | null = null;
|
let existingRecord: SignatureRecord | null = null;
|
||||||
@@ -34,7 +35,7 @@ export const SystemView = () => {
|
|||||||
const newScannedPercentage = parseScannedPercentage(signature.scanned);
|
const newScannedPercentage = parseScannedPercentage(signature.scanned);
|
||||||
|
|
||||||
if (existingRecord) {
|
if (existingRecord) {
|
||||||
const updatedSignature: Pick<SignatureRecord, 'updated' | 'type' | 'name' | 'scanned'> = {
|
const updatedSignature: Pick<SignatureRecord, 'updated' | 'type' | 'name' | 'scanned' | 'note'> = {
|
||||||
updated: new Date().toISOString()
|
updated: new Date().toISOString()
|
||||||
}
|
}
|
||||||
// Existing record has no type and our new signature has a type
|
// Existing record has no type and our new signature has a type
|
||||||
@@ -47,6 +48,8 @@ export const SystemView = () => {
|
|||||||
const existingScannedPercentage = parseScannedPercentage(existingRecord.scanned);
|
const existingScannedPercentage = parseScannedPercentage(existingRecord.scanned);
|
||||||
if (newScannedPercentage >= existingScannedPercentage)
|
if (newScannedPercentage >= existingScannedPercentage)
|
||||||
updatedSignature.scanned = signature.scanned;
|
updatedSignature.scanned = signature.scanned;
|
||||||
|
if (!!!existingRecord.note && !!signature.note)
|
||||||
|
updatedSignature.note = signature.note;
|
||||||
await pb.collection('signature').update(existingRecord.id, updatedSignature);
|
await pb.collection('signature').update(existingRecord.id, updatedSignature);
|
||||||
console.log(`Updated signature ${signature.identifier}: ${existingScannedPercentage}% -> ${newScannedPercentage}%`);
|
console.log(`Updated signature ${signature.identifier}: ${existingScannedPercentage}% -> ${newScannedPercentage}%`);
|
||||||
} else {
|
} else {
|
||||||
@@ -57,7 +60,8 @@ export const SystemView = () => {
|
|||||||
name: signature.signame,
|
name: signature.signame,
|
||||||
type: signature.type,
|
type: signature.type,
|
||||||
dangerous: signature.dangerous,
|
dangerous: signature.dangerous,
|
||||||
scanned: signature.scanned
|
scanned: signature.scanned,
|
||||||
|
note: signature.note
|
||||||
});
|
});
|
||||||
console.log(`Created new signature ${signature.identifier} with ${newScannedPercentage}% scan`);
|
console.log(`Created new signature ${signature.identifier} with ${newScannedPercentage}% scan`);
|
||||||
}
|
}
|
||||||
@@ -125,6 +129,9 @@ export const SystemView = () => {
|
|||||||
if (updatedSignature.scanned !== undefined) {
|
if (updatedSignature.scanned !== undefined) {
|
||||||
updateData.scanned = updatedSignature.scanned;
|
updateData.scanned = updatedSignature.scanned;
|
||||||
}
|
}
|
||||||
|
if (updatedSignature.note !== undefined) {
|
||||||
|
updateData.note = updatedSignature.note;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Update data:', updateData);
|
console.log('Update data:', updateData);
|
||||||
|
|
||||||
@@ -156,12 +163,17 @@ export const SystemView = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const systemId = await getSystemId(system);
|
const systemId = await getSystemId(system);
|
||||||
|
let rules: Array<Pick<SignatureNoteRulesResponse, 'regex' | 'note' | 'enabled'>> = [];
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(Collections.SignatureNoteRules).getFullList<SignatureNoteRulesResponse>({ batch: 1000 });
|
||||||
|
rules = list.filter(r => r.enabled).map(r => ({ regex: r.regex, note: r.note, enabled: r.enabled }));
|
||||||
|
} catch { }
|
||||||
const lines = pastedText.trim().split('\n').filter(line => line.trim());
|
const lines = pastedText.trim().split('\n').filter(line => line.trim());
|
||||||
const parsedSignatures: Omit<Signature, 'id'>[] = [];
|
const parsedSignatures: Omit<Signature, 'id'>[] = [];
|
||||||
|
|
||||||
// Parse all signatures
|
// Parse all signatures
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const parsed = parseSignature(line);
|
const parsed = parseSignature(line, rules);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
parsedSignatures.push({
|
parsedSignatures.push({
|
||||||
...parsed,
|
...parsed,
|
||||||
@@ -199,7 +211,7 @@ export const SystemView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Turn off clean mode after use
|
// Turn off clean mode after use
|
||||||
setCleanMode(false);
|
// setCleanMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save all new/updated signatures
|
// Save all new/updated signatures
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { SigviewRecord as Signature } from "@/lib/pbtypes";
|
import { SigviewRecord as Signature, SignatureNoteRulesResponse } from "@/lib/pbtypes";
|
||||||
|
|
||||||
export const parseSignature = (text: string): Omit<Signature, 'system' | 'id' | 'sysid'> | null => {
|
|
||||||
|
export const parseSignature = (text: string, rules?: Array<Pick<SignatureNoteRulesResponse, 'regex' | 'note' | 'enabled'>>): Omit<Signature, 'system' | 'id' | 'sysid'> | null => {
|
||||||
const parts = text.split('\t');
|
const parts = text.split('\t');
|
||||||
if (parts.length < 4) return null;
|
if (parts.length < 4) return null;
|
||||||
|
|
||||||
@@ -10,12 +11,33 @@ export const parseSignature = (text: string): Omit<Signature, 'system' | 'id' |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appliedNotes: string[] = [];
|
||||||
|
|
||||||
|
if (rules && rules.length > 0) {
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule && rule.enabled) {
|
||||||
|
try {
|
||||||
|
const re = new RegExp(rule.regex, 'i');
|
||||||
|
if (re.test(parts[3])) {
|
||||||
|
appliedNotes.push(rule.note);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid regex - ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupedNotes = Array.from(new Set(appliedNotes)).filter(Boolean);
|
||||||
|
const note = dedupedNotes.join(';');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identifier: parts[0],
|
identifier: parts[0],
|
||||||
type: parts[2],
|
type: parts[2],
|
||||||
signame: parts[3],
|
signame: parts[3],
|
||||||
scanned: parts.length > 4 ? parts[4] : undefined,
|
scanned: parts.length > 4 ? parts[4] : undefined,
|
||||||
dangerous: false // TODO: Implement dangerous signature detection
|
dangerous: false, // TODO: Implement dangerous signature detection
|
||||||
|
note: note,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -4,3 +4,40 @@ export const getSystemId = async (systemName: string): Promise<string> => {
|
|||||||
const system = await pb.collection('regionview').getFirstListItem(`sysname='${systemName}'`);
|
const system = await pb.collection('regionview').getFirstListItem(`sysname='${systemName}'`);
|
||||||
return system.id;
|
return system.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const regionCache: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
export const getSystemRegion = async (systemName: string): Promise<string> => {
|
||||||
|
const key = systemName;
|
||||||
|
const cached = regionCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const rec = await pb.collection('regionview').getFirstListItem(`sysname='${systemName}'`);
|
||||||
|
regionCache.set(key, rec.sysregion);
|
||||||
|
return rec.sysregion as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemsRegions = async (systemNames: string[]): Promise<Record<string, string>> => {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
const pending: string[] = [];
|
||||||
|
for (const name of systemNames) {
|
||||||
|
const cached = regionCache.get(name);
|
||||||
|
if (cached) {
|
||||||
|
result[name] = cached;
|
||||||
|
} else {
|
||||||
|
pending.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.length === 0) return result;
|
||||||
|
// Fetch uncached in parallel
|
||||||
|
const fetched = await Promise.all(
|
||||||
|
pending.map(async (name) => {
|
||||||
|
const rec = await pb.collection('regionview').getFirstListItem(`sysname='${name}'`);
|
||||||
|
regionCache.set(name, rec.sysregion);
|
||||||
|
return { name, region: rec.sysregion as string };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const { name, region } of fetched) {
|
||||||
|
result[name] = region;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
@@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"wailsjs": path.resolve(__dirname, "./wailsjs"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
1
frontend/wails.json
Normal file
1
frontend/wails.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
27
frontend/wailsjs/go/main/App.d.ts
vendored
27
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -1,4 +1,31 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {main} from '../models';
|
||||||
|
|
||||||
|
export function AddWaypointForAllByName(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ESILoggedIn():Promise<boolean>;
|
||||||
|
|
||||||
|
export function ESILoginStatus():Promise<string>;
|
||||||
|
|
||||||
|
export function GetCharacterLocations():Promise<Array<main.CharacterLocation>>;
|
||||||
|
|
||||||
|
export function GetSystemJumps():Promise<Array<main.SystemJumps>>;
|
||||||
|
|
||||||
|
export function GetSystemKills():Promise<Array<main.SystemKills>>;
|
||||||
|
|
||||||
export function Greet(arg1:string):Promise<string>;
|
export function Greet(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ListCharacters():Promise<Array<main.CharacterInfo>>;
|
||||||
|
|
||||||
|
export function ListSystemsWithRegions():Promise<Array<main.SystemRegion>>;
|
||||||
|
|
||||||
|
export function PostRouteForAllByNames(arg1:string,arg2:Array<string>):Promise<void>;
|
||||||
|
|
||||||
|
export function ResolveSystemIDByName(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function SetDestinationForAll(arg1:string,arg2:boolean,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function StartESILogin():Promise<string>;
|
||||||
|
|
||||||
|
export function ToggleCharacterWaypointEnabled(arg1:number):Promise<void>;
|
||||||
|
@@ -2,6 +2,58 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function AddWaypointForAllByName(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AddWaypointForAllByName'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ESILoggedIn() {
|
||||||
|
return window['go']['main']['App']['ESILoggedIn']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ESILoginStatus() {
|
||||||
|
return window['go']['main']['App']['ESILoginStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetCharacterLocations() {
|
||||||
|
return window['go']['main']['App']['GetCharacterLocations']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSystemJumps() {
|
||||||
|
return window['go']['main']['App']['GetSystemJumps']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSystemKills() {
|
||||||
|
return window['go']['main']['App']['GetSystemKills']();
|
||||||
|
}
|
||||||
|
|
||||||
export function Greet(arg1) {
|
export function Greet(arg1) {
|
||||||
return window['go']['main']['App']['Greet'](arg1);
|
return window['go']['main']['App']['Greet'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListCharacters() {
|
||||||
|
return window['go']['main']['App']['ListCharacters']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListSystemsWithRegions() {
|
||||||
|
return window['go']['main']['App']['ListSystemsWithRegions']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostRouteForAllByNames(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['PostRouteForAllByNames'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResolveSystemIDByName(arg1) {
|
||||||
|
return window['go']['main']['App']['ResolveSystemIDByName'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetDestinationForAll(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['SetDestinationForAll'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StartESILogin() {
|
||||||
|
return window['go']['main']['App']['StartESILogin']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleCharacterWaypointEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['ToggleCharacterWaypointEnabled'](arg1);
|
||||||
|
}
|
||||||
|
106
frontend/wailsjs/go/models.ts
Normal file
106
frontend/wailsjs/go/models.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
export namespace main {
|
||||||
|
|
||||||
|
export class CharacterInfo {
|
||||||
|
character_id: number;
|
||||||
|
character_name: string;
|
||||||
|
waypoint_enabled: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new CharacterInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.character_id = source["character_id"];
|
||||||
|
this.character_name = source["character_name"];
|
||||||
|
this.waypoint_enabled = source["waypoint_enabled"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class CharacterLocation {
|
||||||
|
character_id: number;
|
||||||
|
character_name: string;
|
||||||
|
solar_system_id: number;
|
||||||
|
solar_system_name: string;
|
||||||
|
// Go type: time
|
||||||
|
retrieved_at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new CharacterLocation(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.character_id = source["character_id"];
|
||||||
|
this.character_name = source["character_name"];
|
||||||
|
this.solar_system_id = source["solar_system_id"];
|
||||||
|
this.solar_system_name = source["solar_system_name"];
|
||||||
|
this.retrieved_at = this.convertValues(source["retrieved_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SystemJumps {
|
||||||
|
system_id: number;
|
||||||
|
ship_jumps: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SystemJumps(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.system_id = source["system_id"];
|
||||||
|
this.ship_jumps = source["ship_jumps"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SystemKills {
|
||||||
|
system_id: number;
|
||||||
|
ship_kills: number;
|
||||||
|
pod_kills: number;
|
||||||
|
npc_kills: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SystemKills(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.system_id = source["system_id"];
|
||||||
|
this.ship_kills = source["ship_kills"];
|
||||||
|
this.pod_kills = source["pod_kills"];
|
||||||
|
this.npc_kills = source["npc_kills"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SystemRegion {
|
||||||
|
system: string;
|
||||||
|
region: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SystemRegion(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.system = source["system"];
|
||||||
|
this.region = source["region"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
2
frontend/wailsjs/runtime/runtime.d.ts
vendored
2
frontend/wailsjs/runtime/runtime.d.ts
vendored
@@ -134,7 +134,7 @@ export function WindowIsFullscreen(): Promise<boolean>;
|
|||||||
|
|
||||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||||
// Sets the width and height of the window.
|
// Sets the width and height of the window.
|
||||||
export function WindowSetSize(width: number, height: number): Promise<Size>;
|
export function WindowSetSize(width: number, height: number): void;
|
||||||
|
|
||||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||||
// Gets the width and height of the window.
|
// Gets the width and height of the window.
|
||||||
|
47
go.mod
47
go.mod
@@ -1,39 +1,46 @@
|
|||||||
module signalerr
|
module signalerr
|
||||||
|
|
||||||
go 1.21
|
go 1.22.0
|
||||||
|
|
||||||
toolchain go1.23.6
|
toolchain go1.23.6
|
||||||
|
|
||||||
require github.com/wailsapp/wails/v2 v2.9.2
|
require (
|
||||||
|
github.com/wailsapp/wails/v2 v2.10.2
|
||||||
|
gorm.io/driver/sqlite v1.5.7
|
||||||
|
gorm.io/gorm v1.25.10
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
github.com/labstack/echo/v4 v4.10.2 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/labstack/gommon v0.4.0 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
github.com/leaanthony/gosod v1.0.3 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
github.com/leaanthony/u v1.1.0 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.38.1 // indirect
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
github.com/tkrajina/go-reflector v0.5.6 // indirect
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
github.com/wailsapp/go-webview2 v1.0.19 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// replace github.com/wailsapp/wails/v2 v2.9.2 => C:\Users\Administrator\go\pkg\mod
|
// replace github.com/wailsapp/wails/v2 v2.9.2 => C:\Users\Administrator\go\pkg\mod
|
||||||
|
109
go.sum
109
go.sum
@@ -1,94 +1,91 @@
|
|||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
github.com/leaanthony/gosod v1.0.3 h1:Fnt+/B6NjQOVuCWOKYRREZnjGyvg+mEhd1nkkA04aTQ=
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
github.com/leaanthony/gosod v1.0.3/go.mod h1:BJ2J+oHsQIyIQpnLPjnqFGTMnOZXDbvWtRCSG7jGxs4=
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
|
|
||||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
|
||||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/wailsapp/go-webview2 v1.0.16 h1:wffnvnkkLvhRex/aOrA3R7FP7rkvOqL/bir1br7BekU=
|
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
|
||||||
github.com/wailsapp/go-webview2 v1.0.16/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
|
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k=
|
github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk=
|
||||||
github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs=
|
github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
|
||||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
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-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||||
|
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
|
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||||
|
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
BIN
signalerr.exe
Normal file
BIN
signalerr.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user