31 Commits

Author SHA1 Message Date
f7879c7ea8 feat(RegionMap): add context menu for background clicks to show nearest system 2025-08-10 22:38:40 +02:00
22ef386ea2 fix(RegionMap.tsx): update onSetDestination prop to accept 'via' parameter for improved functionality 2025-08-10 22:34:00 +02:00
51179485a1 fix(RegionMap): enhance system focus visual feedback with improved styling and animation 2025-08-10 22:31:52 +02:00
11fda4e11f feat(search): implement Aho-Corasick for efficient substring searching and improve result ordering 2025-08-10 22:29:15 +02:00
7af7d9ecd0 refactor(SearchDialog.tsx): simplify system loading by directly calling ListSystemsWithRegions 2025-08-10 22:26:54 +02:00
97178bc9a5 feat(go, ts): add system-region mapping and highlight functionality 2025-08-10 22:26:02 +02:00
2561cd7d30 feat(frontend): load systems from PocketBase and add system focus to region page 2025-08-10 22:18:17 +02:00
9c40135102 feat(frontend): implement system search dialog with Aho-Corasick algorithm 2025-08-10 22:06:55 +02:00
90b190b8d5 Code format 2025-08-09 21:34:02 +02:00
c10f4b43cb refactor(RegionMap.tsx): rename meanInboundAngle to meanNeighborAngle and update logic to compute mean angle to neighbors 2025-08-09 21:33:13 +02:00
e2f804bac7 fix(RegionMap.tsx): flip connection indicator angle by 180 degrees to point away from existing connections 2025-08-09 21:23:09 +02:00
91cbb6c841 feat(app): add location read scope and update map angle calculations 2025-08-09 21:17:23 +02:00
fb3ebc10ff feat(app): add character location tracking and display 2025-08-09 20:54:05 +02:00
2a098ec0d2 refactor(RegionMap.tsx): improve off-region link indicators with better caching and visualization 2025-08-09 20:44:49 +02:00
ee2a1fcde0 Update 2025-08-09 20:22:50 +02:00
a584bc9c55 refactor(esi_sso.go): parallelize esi token processing and deduplicate tokens to improve performance 2025-08-09 20:11:34 +02:00
e1e961ebea refactor(esi_sso): simplify system ID resolution by using a local cache 2025-08-09 20:04:37 +02:00
f06a60c701 feat(esi): add PostRouteForAllByNames and resolve system names in batch
This commit introduces a new function `PostRouteForAllByNames` to the ESI service, which allows setting a complete route (including waypoints) for all logged-in characters. This is achieved by batch resolving system names to their IDs, improving efficiency and simplifying the process of setting complex routes.

The changes include:
- Adding `ResolveSystemIDsByNames` to `ESISSO` to fetch multiple system IDs in a single ESI request.
- Implementing `PostRouteForAll` in `ESISSO` to handle the logic of setting the destination and waypoints for all characters.
- Updating `App.go` to expose `PostRouteForAllByNames` for frontend use.
- Modifying the frontend component `RegionMap.tsx` to utilize the new `PostRouteForAllByNames` function when setting routes, replacing the previous sequential calls to `SetDestinationForAll` and `AddWaypointForAllByName`.
- Updating Wails generated type definitions (`.d.ts` and `.js`) to reflect the new function.
2025-08-09 20:01:59 +02:00
ef57bf4cde feat(frontend): implement via mode for setting routes with waypoints 2025-08-09 19:57:04 +02:00
cd1cc6dc5f feat(frontend): implement via mode for setting destinations and add MapNode disableNavigate prop 2025-08-09 19:51:53 +02:00
e7a8014a50 feat(app): add waypoint functionality to map and context menu 2025-08-09 19:37:02 +02:00
13da1c8340 feat(app): implement character login and destination setting for multiple characters
This commit introduces several key features:

- **Multiple Character Support**: The application can now handle multiple logged-in EVE Online characters.
- **List Characters**: A new function `ListCharacters` allows retrieving a list of all authenticated characters.
- **Set Destination for All**: The `SetDestinationForAll` function enables setting a destination for all logged-in characters simultaneously.
- **UI Updates**: The frontend has been updated to display logged-in characters and to allow setting destinations for all characters.
- **Backend Refinements**: The ESI SSO logic has been improved to support refreshing tokens for multiple characters and to handle the new multi-character functionality.
- **Dependency Updates**: Dependencies have been updated to their latest versions.

chore: update go module dependencies
2025-08-09 19:30:51 +02:00
3f9d315978 feat(esi): integrate GORM for local ESI token storage and system ID resolution 2025-08-09 19:18:09 +02:00
ca610000db feat(esi): improve ESI SSO login flow and system name resolution
This commit introduces several improvements to the ESI (EVE Server Interface) Single Sign-On (SSO) flow and system name resolution:

**ESI SSO Login Flow:**
- **Asynchronous Callback Server:** The `StartCallbackServer` function is now deprecated in favor of `StartCallbackServerAsync`. This allows the callback server to run in the background without blocking the main application thread, improving responsiveness.
- **Improved Login Status Polling:** After initiating the ESI login, the frontend now polls the `ESILoggedIn` status for a short period. This ensures that the UI reflects the login status more accurately and promptly after the user completes the authentication flow in their browser.
- **Error Handling:** Added more specific error messages for failed token exchanges and invalid SSO responses.

**System Name Resolution:**
- **Multi-stage Resolution:** The `ResolveSystemIDByName` function now employs a more robust, multi-stage approach to find system IDs:
 1. It first attempts to use the `universe/ids` endpoint for direct name-to-ID mapping, which is generally more accurate.
 2. If that fails, it falls back to a `strict` search via the `search` endpoint.
 3. As a final fallback, it performs a non-strict search and then resolves the names of the returned IDs to find an exact case-insensitive match. If no exact match is found, it returns the first result.
- **Logging:** Added more detailed logging for each stage of the system name resolution process, aiding in debugging.
- **ESI API Headers:** Ensured that necessary headers like `Accept` and `X-User-Agent` are correctly set for ESI API requests.

**Frontend Changes:**
- **Import `ESILoggedIn`:** The `ESILoggedIn` function is now imported into the `Header.tsx` component.
- **Updated Toast Message:** The toast message for setting a destination now includes the system name for better context in case of errors.
2025-08-09 19:08:05 +02:00
33fcaaaf52 feat(vite.config.ts): add wailsjs alias for improved frontend module resolution 2025-08-09 18:59:05 +02:00
1b73642940 refactor(wailsjs): update wailsjs import paths to use relative paths 2025-08-09 18:54:24 +02:00
aa15a8c2c9 feat(go.mod): update wails to v2.10.2 and other dependencies 2025-08-09 18:51:54 +02:00
478a628b6f feat(app): implement EVE SSO login and waypoint setting functionality 2025-08-09 18:49:36 +02:00
98b6397dcc Add support for 1/10 2/10 3/10 2025-07-07 11:39:00 +02:00
715e4559aa Fix saving notes for automatically categorized signatures 2025-07-02 18:21:42 +02:00
d42c245c9d Fix dockerfile failing build 2025-06-25 15:43:44 +02:00
45 changed files with 1861 additions and 275 deletions

135
app.go
View File

@@ -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,138 @@ 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 = "5091f74037374697938384bdbac2698c"
}
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-ui.write_waypoint.v1", "esi-location.read_location.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})
}
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)
}
// 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
}

709
esi_sso.go Normal file
View File

@@ -0,0 +1,709 @@
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
UpdatedAt time.Time
CreatedAt time.Time
}
type CharacterInfo struct {
CharacterID int64 `json:"character_id"`
CharacterName string `json:"character_name"`
}
// 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"`
}
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
}
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) 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()
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}
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()
fmt.Printf("ESI: POST waypoint dest=%d clear=%v addToBeginning=%v\n", destinationID, clearOthers, addToBeginning)
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 {
fmt.Println("ESI: waypoint set OK", resp.Status)
return nil
}
b, _ := io.ReadAll(resp.Body)
fmt.Printf("ESI: waypoint failed %s body=%s\n", resp.Status, string(b))
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 {
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
}
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
uniq := make(map[int64]ESIToken, len(tokens))
for _, t := range tokens {
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
}

View File

@@ -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

View File

@@ -6,6 +6,7 @@ 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";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -21,6 +22,7 @@ function App() {
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
<Toaster /> <Toaster />
<SearchDialog />
</Router> </Router>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -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,9 @@ 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 } from 'wailsjs/go/main/App';
interface HeaderProps { interface HeaderProps {
title: string; title: string;
@@ -18,12 +21,42 @@ interface HeaderProps {
}>; }>;
} }
interface CharacterInfo { character_id: number; character_name: string }
export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => { export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [chars, setChars] = useState<CharacterInfo[]>([]);
const refreshState = async () => {
try {
const list = await ListCharacters();
setChars((list as any[]).map((c: any) => ({ character_id: c.character_id, character_name: c.character_name })));
} 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' });
}
};
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>
@@ -51,9 +84,22 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
</Breadcrumb> </Breadcrumb>
</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">
{chars.length > 0 && (
<div className="flex flex-wrap gap-2 max-w-[50vw] justify-end">
{chars.map((c) => (
<span key={c.character_id} className="px-2 py-1 rounded-full bg-purple-500/20 text-purple-200 border border-purple-400/40 text-xs whitespace-nowrap">
{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>
); );
}; };

View File

@@ -15,6 +15,7 @@ interface MapNodeProps {
security?: number; security?: number;
signatures?: number; signatures?: number;
isDraggable?: boolean; isDraggable?: boolean;
disableNavigate?: boolean;
} }
export const MapNode: React.FC<MapNodeProps> = ({ export const MapNode: React.FC<MapNodeProps> = ({
@@ -30,7 +31,8 @@ export const MapNode: React.FC<MapNodeProps> = ({
type, type,
security, security,
signatures, signatures,
isDraggable = false isDraggable = false,
disableNavigate = false,
}) => { }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -65,14 +67,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 +128,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

View File

@@ -8,6 +8,9 @@ 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';
interface RegionMapProps { interface RegionMapProps {
regionName: string; regionName: string;
@@ -54,6 +57,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 +90,187 @@ 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 [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);
// When focusSystem changes, set an expiry 20s in the future
useEffect(() => {
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); const { data: rsystems, isLoading, error } = useRegionData(regionName);
useEffect(() => { useEffect(() => {
if (!isLoading && error == null && rsystems && rsystems.size > 0) if (!isLoading && error == null && rsystems && rsystems.size > 0)
setSystems(rsystems); setSystems(rsystems);
}, [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 +279,31 @@ 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 => {
if (prev.includes(systemName)) return prev;
const next = [...prev, systemName];
return next;
});
console.log('Queued waypoint:', systemName);
toast({ title: 'Waypoint queued', description: systemName });
return;
}
if (focusSystem === systemName) return; if (focusSystem === systemName) return;
navigate(`/regions/${regionName}/${systemName}`); navigate(`/regions/${regionName}/${systemName}`);
}; };
@@ -231,36 +441,20 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
if (!svgRef.current) return; if (!svgRef.current) return;
setIsPanning(true); setIsPanning(true);
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
setLastPanPoint({ setLastPanPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top });
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
}, []); }, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning || !svgRef.current) return; if (!isPanning || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
const currentPoint = { const currentPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
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]); }, [isPanning, lastPanPoint, viewBox.width, viewBox.height]);
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => { setIsPanning(false); }, []);
setIsPanning(false);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
@@ -288,8 +482,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 +596,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);
@@ -406,23 +657,12 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
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="cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={(e) => { onMouseMove={(e) => { if (isPanning) { handleMouseMove(e); } else if (draggingNode) { handleSvgMouseMove(e); } }}
if (isPanning) { onMouseUp={(e) => { if (isPanning) { handleMouseUp(); } else if (draggingNode) { handleSvgMouseUp(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 +672,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 */}
@@ -470,34 +713,82 @@ 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}
/> />
))} ))}
{/* 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] && (
<circle <g style={{ pointerEvents: 'none' }}>
cx={positions[focusSystem].x} <circle
cy={positions[focusSystem].y} cx={positions[focusSystem].x}
r="15" cy={positions[focusSystem].y}
fill="none" r="20"
stroke="#a855f7" fill="none"
strokeWidth="3" stroke="#a855f7"
strokeDasharray="5,5" strokeWidth="3"
opacity="0.8" opacity="0.9"
> filter="url(#glow)"
<animateTransform >
attributeName="transform" <animate
attributeType="XML" attributeName="r"
type="rotate" values="18;22;18"
from={`0 ${positions[focusSystem].x} ${positions[focusSystem].y}`} dur="1.5s"
to={`360 ${positions[focusSystem].x} ${positions[focusSystem].y}`} repeatCount="indefinite"
dur="10s" />
repeatCount="indefinite" </circle>
/> <text x={positions[focusSystem].x + 12} y={positions[focusSystem].y - 10} fontSize="10" fill="#ffffff" stroke="#0f172a" strokeWidth="2" paintOrder="stroke">{focusSystem}</text>
</circle> </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>
)}
{/* Context Menu */} {/* Context Menu */}
{contextMenu && ( {contextMenu && (
<SystemContextMenu <SystemContextMenu
@@ -507,6 +798,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)}
/> />
)} )}

View File

@@ -0,0 +1,116 @@
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 } from 'wailsjs/go/main/App';
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)}`);
};
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={() => onSelect(r)}
>
<div className="text-sm font-medium">{r.system}</div>
<div className="text-xs text-slate-300">{r.region}</div>
</button>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,15 +3,15 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { SigviewRecord as Signature } from "@/lib/pbtypes"; import { SigviewRecord as Signature } from "@/lib/pbtypes";
import { getSignatureMeta } from "@/hooks/useSignatureCategories"; import { getSignatureMeta } from "@/hooks/useSignatureCategories";
@@ -52,7 +52,7 @@ export const SignatureCard = ({ signature, onDelete, onUpdate }: SignatureCardPr
return ( return (
<> <>
<Card <Card
className={`${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)]' : 'bg-slate-800/40 border-slate-700'} hover:bg-slate-800/60 transition-all duration-200 hover:border-slate-600 relative cursor-pointer`} className={`${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)]' : 'bg-slate-800/40 border-slate-700'} hover:bg-slate-800/60 transition-all duration-200 hover:border-slate-600 relative cursor-pointer`}
onClick={() => setIsEditModalOpen(true)} onClick={() => setIsEditModalOpen(true)}
> >
@@ -60,15 +60,15 @@ export const SignatureCard = ({ signature, onDelete, onUpdate }: SignatureCardPr
<div className="space-y-3"> <div className="space-y-3">
{/* Type Badge - Most Important */} {/* Type Badge - Most Important */}
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<Badge <Badge
variant="outline" variant="outline"
className={`${meta.color} px-3 py-1 text-sm font-semibold flex items-center gap-2`} className={`${meta.color} px-3 py-1 text-sm font-semibold flex items-center gap-2`}
> >
{meta.icon} {meta.icon}
{signature.type || 'Unknown Type'} {signature.type || 'Unknown Type'}
</Badge> </Badge>
</div> </div>
{/* Signature Name */} {/* Signature Name */}
<div className="text-center"> <div className="text-center">
<h3 className="text-white font-medium text-lg"> <h3 className="text-white font-medium text-lg">
@@ -82,7 +82,7 @@ export const SignatureCard = ({ signature, onDelete, onUpdate }: SignatureCardPr
</div> </div>
)} )}
</div> </div>
{/* Additional Info */} {/* Additional Info */}
<div className="space-y-2 text-sm text-slate-400"> <div className="space-y-2 text-sm text-slate-400">
<div className="flex justify-between"> <div className="flex justify-between">

View File

@@ -53,9 +53,9 @@ export const SignatureCategories = ({ categories, onToggleCategory, onDelete, on
<CardContent className="p-0"> <CardContent className="p-0">
<div className="divide-y divide-slate-700"> <div className="divide-y divide-slate-700">
{category.signatures.map((signature) => ( {category.signatures.map((signature) => (
<SignatureListItem <SignatureListItem
key={signature.id} key={signature.id}
signature={signature} signature={signature}
onDelete={onDelete} onDelete={onDelete}
onUpdate={onUpdate} onUpdate={onUpdate}
/> />

View File

@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";

View File

@@ -2,15 +2,15 @@ import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Clock, AlertTriangle, Skull, Trash2 } from "lucide-react"; import { Clock, AlertTriangle, Skull, Trash2 } from "lucide-react";
import { SigviewRecord as Signature } from "@/lib/pbtypes"; import { SigviewRecord as Signature } from "@/lib/pbtypes";
@@ -88,9 +88,8 @@ 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)}
> >
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">

View File

@@ -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>

View File

@@ -25,7 +25,7 @@ const badgeVariants = cva(
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (

View File

@@ -35,7 +35,7 @@ const buttonVariants = cva(
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
} }

View File

@@ -82,13 +82,13 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
([theme, prefix]) => ` ([theme, prefix]) => `
${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color itemConfig.color
return color ? ` --color-${key}: ${color};` : null return color ? ` --color-${key}: ${color};` : null
}) })
.join("\n")} .join("\n")}
} }
` `
) )
@@ -103,13 +103,13 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean
hideIndicator?: boolean hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
} }
>( >(
( (
{ {
@@ -259,10 +259,10 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }
>( >(
( (
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
@@ -326,8 +326,8 @@ function getPayloadConfigFromPayload(
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload &&
typeof payload.payload === "object" && typeof payload.payload === "object" &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined : undefined

View File

@@ -21,7 +21,7 @@ const Command = React.forwardRef<
)) ))
Command.displayName = CommandPrimitive.displayName Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {} interface CommandDialogProps extends DialogProps { }
const CommandDialog = ({ children, ...props }: CommandDialogProps) => { const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return ( return (

View File

@@ -11,7 +11,7 @@ const labelVariants = cva(
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root
ref={ref} ref={ref}

View File

@@ -31,9 +31,9 @@ const ScrollBar = React.forwardRef<
className={cn( className={cn(
"flex touch-none select-none transition-colors", "flex touch-none select-none transition-colors",
orientation === "vertical" && orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]", "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]", "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className className
)} )}
{...props} {...props}

View File

@@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
)} )}
position={position} position={position}
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)} )}
> >
{children} {children}

View File

@@ -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
} }

View File

@@ -612,7 +612,7 @@ const SidebarMenuAction = React.forwardRef<
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className className
)} )}
{...props} {...props}

View File

@@ -3,7 +3,7 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface TextareaProps export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {

View File

@@ -41,7 +41,7 @@ const toastVariants = cva(
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root

View File

@@ -15,7 +15,7 @@ const ToggleGroupContext = React.createContext<
const ToggleGroup = React.forwardRef< const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>, React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => ( >(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
ref={ref} ref={ref}
@@ -33,7 +33,7 @@ ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef< const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>, React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => { >(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext) const context = React.useContext(ToggleGroupContext)

View File

@@ -29,7 +29,7 @@ const toggleVariants = cva(
const Toggle = React.forwardRef< const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>, React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => ( >(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root <TogglePrimitive.Root
ref={ref} ref={ref}

View File

@@ -32,21 +32,21 @@ type ActionType = typeof actionTypes
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"]
toast: ToasterToast toast: ToasterToast
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast> toast: Partial<ToasterToast>
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"]
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"]
} }
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[]
@@ -104,9 +104,9 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined t.id === toastId || toastId === undefined
? { ? {
...t, ...t,
open: false, open: false,
} }
: t : t
), ),
} }

View File

@@ -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,12 +74,14 @@
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;
overflow: hidden; overflow: hidden;
} }
} }

80
frontend/src/lib/aho.ts Normal file
View 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;
}
}

View File

@@ -25,8 +25,8 @@ export type HTMLString = string
type ExpandType<T> = unknown extends T type ExpandType<T> = unknown extends T
? T extends unknown ? T extends unknown
? { expand?: unknown } ? { expand?: unknown }
: { expand: T } : { expand: T }
: { expand: T } : { expand: T }
// System fields // System fields

View File

@@ -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 />);

View File

@@ -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,15 +6,17 @@ 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) {
return ( return (
<div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center overflow-hidden"> <div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center overflow-hidden">
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">Region Not Found</h1> <h1 className="text-4xl font-bold text-white mb-4">Region Not Found</h1>
<p className="text-purple-200 mb-6">The requested region does not exist in our database.</p> <p className="text-purple-200 mb-6">The requested region does not exist in our database.</p>
<Button <Button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="bg-purple-600 hover:bg-purple-700" className="bg-purple-600 hover:bg-purple-700"
> >
@@ -28,9 +30,10 @@ export const RegionPage = () => {
return ( 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-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 overflow-hidden">
<RegionMap <RegionMap
regionName={region} regionName={region}
isWormholeRegion={region === "Wormhole"} isWormholeRegion={region === "Wormhole"}
focusSystem={focus}
/> />
</div> </div>
); );

View File

@@ -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`);
} }
@@ -70,10 +74,10 @@ export const SystemView = () => {
const deleteSignature = async (signatureId: string): Promise<void> => { const deleteSignature = async (signatureId: string): Promise<void> => {
try { try {
await pb.collection('signature').delete(signatureId); await pb.collection('signature').delete(signatureId);
// Invalidate queries to refresh the data // Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['signatures', system] }); queryClient.invalidateQueries({ queryKey: ['signatures', system] });
toast({ toast({
title: "Signature Deleted", title: "Signature Deleted",
description: "The signature has been successfully deleted.", description: "The signature has been successfully deleted.",
@@ -93,14 +97,14 @@ export const SystemView = () => {
try { try {
// Get the system ID for the current system // Get the system ID for the current system
const systemId = await getSystemId(system); const systemId = await getSystemId(system);
console.log('Updating signature:', { console.log('Updating signature:', {
identifier: updatedSignature.identifier, identifier: updatedSignature.identifier,
systemId, systemId,
system, system,
updatedSignature updatedSignature
}); });
// Find the signature by identifier and system // Find the signature by identifier and system
const existingRecord = await pb.collection('signature').getFirstListItem( const existingRecord = await pb.collection('signature').getFirstListItem(
`identifier='${updatedSignature.identifier}' && system='${systemId}'` `identifier='${updatedSignature.identifier}' && system='${systemId}'`
@@ -132,10 +136,10 @@ export const SystemView = () => {
console.log('Update data:', updateData); console.log('Update data:', updateData);
await pb.collection('signature').update(existingRecord.id, updateData); await pb.collection('signature').update(existingRecord.id, updateData);
// Invalidate queries to refresh the data // Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['signatures', system] }); queryClient.invalidateQueries({ queryKey: ['signatures', system] });
toast({ toast({
title: "Signature Updated", title: "Signature Updated",
description: "The signature has been successfully updated.", description: "The signature has been successfully updated.",

View File

@@ -1,7 +1,7 @@
export const getSecurityColor = (security: number): string => { export const getSecurityColor = (security: number): string => {
// Clamp security between -1 and 1 // Clamp security between -1 and 1
const clampedSecurity = Math.max(-1, Math.min(1, security)); const clampedSecurity = Math.max(-1, Math.min(1, security));
// Define color points for specific security values // Define color points for specific security values
const colorPoints = [ const colorPoints = [
{ sec: -1.0, color: [75, 0, 130] }, // Dark purple (same as 0.1) { sec: -1.0, color: [75, 0, 130] }, // Dark purple (same as 0.1)
@@ -31,7 +31,7 @@ export const getSecurityColor = (security: number): string => {
// Calculate the ratio between the two points // Calculate the ratio between the two points
const ratio = (clampedSecurity - lowerPoint.sec) / (upperPoint.sec - lowerPoint.sec); const ratio = (clampedSecurity - lowerPoint.sec) / (upperPoint.sec - lowerPoint.sec);
// Interpolate between the colors // Interpolate between the colors
const red = Math.round(lowerPoint.color[0] + (upperPoint.color[0] - lowerPoint.color[0]) * ratio); const red = Math.round(lowerPoint.color[0] + (upperPoint.color[0] - lowerPoint.color[0]) * ratio);
const green = Math.round(lowerPoint.color[1] + (upperPoint.color[1] - lowerPoint.color[1]) * ratio); const green = Math.round(lowerPoint.color[1] + (upperPoint.color[1] - lowerPoint.color[1]) * ratio);

View File

@@ -1,5 +1,28 @@
import { SigviewRecord as Signature } from "@/lib/pbtypes"; import { SigviewRecord as Signature } from "@/lib/pbtypes";
const oneOutOfTen = [
"Minmatar Contracted Bio-Farm",
"Old Meanie - Cultivation Center",
"Pith Robux Asteroid Mining & Co.",
"Sansha Military Outpost",
"Serpentis Drug Outlet",
];
const twoOutOfTen = [
"Angel Creo-Corp Mining",
"Blood Raider Human Farm",
"Pith Merchant Depot",
"Sansha Acclimatization Facility",
"Serpentis Live Cargo Distribution Facilities",
"Rogue Drone Infestation Sprout",
];
const threeOutOfTen = [
"Angel Repurposed Outpost",
"Blood Raider Intelligence Collection Point",
"Guristas Guerilla Grounds",
"Sansha's Command Relay Outpost",
"Serpentis Narcotic Warehouses",
"Rogue Drone Asteroid Infestation",
];
const fourOutOfTen = [ const fourOutOfTen = [
"Angel Cartel Occupied Mining Colony", "Angel Cartel Occupied Mining Colony",
"Mul-Zatah Monastery", "Mul-Zatah Monastery",

View File

@@ -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;
};

View File

@@ -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
View File

@@ -0,0 +1 @@

View File

@@ -1,4 +1,23 @@
// 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 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 SetDestinationForAll(arg1:string,arg2:boolean,arg3:boolean):Promise<void>;
export function StartESILogin():Promise<string>;

View File

@@ -2,6 +2,42 @@
// 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 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 SetDestinationForAll(arg1, arg2, arg3) {
return window['go']['main']['App']['SetDestinationForAll'](arg1, arg2, arg3);
}
export function StartESILogin() {
return window['go']['main']['App']['StartESILogin']();
}

View File

@@ -0,0 +1,72 @@
export namespace main {
export class CharacterInfo {
character_id: number;
character_name: string;
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"];
}
}
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 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"];
}
}
}

View File

@@ -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
View File

@@ -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
View File

@@ -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

Binary file not shown.