From dc0b091a5d872830a0fecc034cee6aecb14397be Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:37:59 -0600 Subject: [PATCH 01/54] feat(i18n): implement internationalization support with language management and translation loading --- internal/i18n/i18n.go | 237 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 internal/i18n/i18n.go diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..6dced17 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,237 @@ +package i18n + +import ( + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/sthbryan/ftm/internal/config" + "golang.org/x/text/language" + "gopkg.in/yaml.v3" +) + +const ( + LangEN = "en" + LangES = "es" +) + +const DefaultLang = LangEN + +var ( + currentLang = DefaultLang + currentLangOnce sync.Once +) + +type TranslationStore struct { + mu sync.RWMutex + translations map[string]map[string]string +} + +var store = &TranslationStore{ + translations: make(map[string]map[string]string), +} + +func T(key string) string { + return store.T(key, currentLang) +} + +func TLang(lang, key string) string { + return store.T(key, lang) +} + +func GetCurrentLang() string { + return currentLang +} + +func SetLanguage(lang string) { + currentLangOnce.Do(func() {}) + store.mu.Lock() + defer store.mu.Unlock() + if _, ok := store.translations[lang]; ok { + currentLang = lang + } +} + +func SetLanguageWithFallback(lang string) { + store.mu.RLock() + _, ok := store.translations[lang] + store.mu.RUnlock() + + if ok { + currentLang = lang + } else { + currentLang = DefaultLang + } +} + +func (s *TranslationStore) T(key, lang string) string { + s.mu.RLock() + defer s.mu.RUnlock() + + if translations, ok := s.translations[lang]; ok { + if val, ok := translations[key]; ok { + return val + } + } + + if lang != DefaultLang { + if translations, ok := s.translations[DefaultLang]; ok { + if val, ok := translations[key]; ok { + return val + } + } + } + + return key +} + +func LoadTranslations(lang string, data map[string]string) { + store.mu.Lock() + defer store.mu.Unlock() + store.translations[lang] = data +} + +func LoadFromYAML(lang string, content []byte) error { + store.mu.Lock() + defer store.mu.Unlock() + + var data map[string]string + if err := yaml.Unmarshal(content, &data); err != nil { + return fmt.Errorf("failed to parse YAML for lang %s: %w", lang, err) + } + + store.translations[lang] = data + return nil +} + +func InitFromConfig(cfg *config.Config) { + + systemLang := detectSystemLang() + + if cfg.Language != "" { + SetLanguageWithFallback(cfg.Language) + return + } + + SetLanguageWithFallback(systemLang) +} + +func detectSystemLang() string { + lang := os.Getenv("LANG") + if lang == "" { + return DefaultLang + } + + tag, err := language.Parse(lang) + if err != nil { + return DefaultLang + } + + base, _ := tag.Base() + switch base.String() { + case "en": + return LangEN + case "es": + return LangES + default: + return DefaultLang + } +} + +func SupportedLanguages() []string { + return []string{LangEN, LangES} +} + +func LanguageName(code string) string { + switch code { + case LangEN: + return "English" + case LangES: + return "Español" + default: + return code + } +} + +func LanguageTag(code string) string { + switch code { + case LangEN: + return "en-US" + case LangES: + return "es-ES" + default: + return "en-US" + } +} + +func ParseAcceptLanguage(header string) string { + if header == "" { + return DefaultLang + } + + parts := strings.Split(header, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if idx := strings.Index(part, ";"); idx != -1 { + part = part[:idx] + } + + tag, err := language.Parse(part) + if err != nil { + continue + } + + base, _ := tag.Base() + switch base.String() { + case "en": + return LangEN + case "es": + return LangES + } + } + + return DefaultLang +} + +func LoadFromFS(fs embed.FS, prefix string) error { + languages := SupportedLanguages() + + for _, lang := range languages { + path := filepath.Join(prefix, lang+".yaml") + content, err := fs.ReadFile(path) + if err != nil { + continue + } + + if err := LoadFromYAML(lang, content); err != nil { + return err + } + } + + return nil +} + +func AddFallback(lang string) { + store.mu.Lock() + defer store.mu.Unlock() + + en, ok := store.translations[DefaultLang] + if !ok { + return + } + + current, ok := store.translations[lang] + if !ok { + store.translations[lang] = en + return + } + + for k, v := range en { + if _, exists := current[k]; !exists { + current[k] = v + } + } +} From 3e2e4bfe099334508029857cd1d703cf974cfb85 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:38:41 -0600 Subject: [PATCH 02/54] feat(i18n): add core i18n package with EN/ES locales --- internal/i18n/embed.go | 21 +++++ internal/i18n/helpers.go | 71 +++++++++++++++++ internal/i18n/locales/en.yaml | 143 ++++++++++++++++++++++++++++++++++ internal/i18n/locales/es.yaml | 143 ++++++++++++++++++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 internal/i18n/embed.go create mode 100644 internal/i18n/helpers.go create mode 100644 internal/i18n/locales/en.yaml create mode 100644 internal/i18n/locales/es.yaml diff --git a/internal/i18n/embed.go b/internal/i18n/embed.go new file mode 100644 index 0000000..f99107b --- /dev/null +++ b/internal/i18n/embed.go @@ -0,0 +1,21 @@ +package i18n + +import ( + "embed" + "os" +) + +//go:embed locales/*.yaml +var localeFS embed.FS + +func Load() error { + return LoadFromFS(localeFS, "locales") +} + +func LoadExtra(lang, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return LoadFromYAML(lang, data) +} diff --git a/internal/i18n/helpers.go b/internal/i18n/helpers.go new file mode 100644 index 0000000..80fca84 --- /dev/null +++ b/internal/i18n/helpers.go @@ -0,0 +1,71 @@ +package i18n + +var Tr = T + +func StatusText(state string) string { + switch state { + case "online": + return T("online") + case "offline": + return T("offline") + case "connecting": + return T("connecting") + case "error": + return T("error") + case "timeout": + return T("timeout") + case "starting": + return T("starting") + case "stopping": + return T("stopping") + default: + return state + } +} + +func ProviderText(provider string) string { + switch provider { + case "pinggy": + return T("provider_pinggy") + case "serveo": + return T("provider_serveo") + case "cloudflared": + return T("provider_cloudflared") + case "tunnelmole": + return T("provider_tunnelmole") + case "localhostrun": + return T("provider_localhostrun") + default: + return provider + } +} + +func NotificationText(key string, args ...string) string { + msg := T(key) + + for i, arg := range args { + placeholder := "{" + string(rune('0'+i)) + "}" + msg = replaceAll(msg, placeholder, arg) + } + return msg +} + +func replaceAll(s, old, new string) string { + for { + idx := indexOf(s, old) + if idx == -1 { + break + } + s = s[:idx] + new + s[idx+len(old):] + } + return s +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml new file mode 100644 index 0000000..7af8e63 --- /dev/null +++ b/internal/i18n/locales/en.yaml @@ -0,0 +1,143 @@ +# English translations + +# App +app_name: "Foundry Tunnel Manager" +app_description: "Manage tunnels to expose Foundry VTT" + +# General +ok: "OK" +cancel: "Cancel" +close: "Close" +save: "Save" +delete: "Delete" +edit: "Edit" +create: "Create" +start: "Start" +stop: "Stop" +restart: "Restart" +status: "Status" +name: "Name" +type: "Type" +port: "Port" +url: "URL" + +# Status +online: "Online" +offline: "Offline" +connecting: "Connecting..." +error: "Error" +timeout: "Timeout" +starting: "Starting..." +stopping: "Stopping..." + +# Providers +provider_pinggy: "Pinggy" +provider_serveo: "Serveo" +provider_cloudflared: "Cloudflared" +provider_tunnelmole: "Tunnelmole" +provider_localhostrun: "LocalhostRun" + +# Actions +connect: "Connect" +disconnect: "Disconnect" +copy_url: "Copy URL" +open_browser: "Open in Browser" +configure: "Configure" + +# Form labels +tunnel_name: "Tunnel Name" +select_provider: "Select Provider" +local_port: "Local Port" +ssh_key: "SSH Key (optional)" +provider_options: "Provider Options" + +# Messages +confirm_delete: "Are you sure you want to delete this tunnel?" +confirm_stop: "Are you sure you want to stop this tunnel?" +copied: "Copied to clipboard" +connection_failed: "Connection failed" +connection_success: "Connection successful" +tunnel_expired: "Tunnel expired" +tunnel_expiring: "Tunnel expiring soon" + +# Notifications +notification_tunnel_online: "{name} is now online" +notification_tunnel_offline: "{name} is now offline" +notification_tunnel_error: "{name} encountered an error" +notification_tunnel_expiring: "{name} expires in {minutes} minutes" +notification_tunnel_expired: "{name} has expired" + +# Settings +settings: "Settings" +language: "Language" +theme: "Theme" +notifications: "Notifications" +enable_notifications: "Enable Notifications" +notification_sound: "Notification Sound" +expiration_warnings: "Expiration Warnings" + +# Help +help: "Help" +press_enter: "Press Enter to select" +press_esc: "Press Esc to go back" +press_tab: "Press Tab to switch" +navigation_hint: "↑↓ Navigate • Enter Select • Esc Back" + +# Errors +error_loading_config: "Error loading configuration" +error_saving_config: "Error saving configuration" +error_invalid_port: "Invalid port number" +error_invalid_name: "Invalid tunnel name" +error_connection_refused: "Connection refused" + +# Labels +connections: "Your Connections" +selected_tunnel: "Selected Tunnel" +select_tunnel_details: "Select a tunnel to view details" +provider_label: "Provider" +port_label: "Port" +status_label: "Status" +start_action: "Start" +stop_action: "Stop" +logs_action: "Logs" +delete_action: "Delete" +press_c_copy: "Press 'c' to copy" + +# Form +new_tunnel: "✨ New Tunnel" +new_tunnel_desc: "Create a secure tunnel to your local service" +edit_tunnel: "✏️ Edit Tunnel" +edit_tunnel_desc: "Modify your tunnel settings" +name_label: "Name" +tunnel_name_hint: "your tunnel identifier" +provider_hint: "cloudflare | pinggy | serveo" +port_hint: "e.g. 3000, 8080" +type_hint: "type to enter" +arrow_hint: "← → to change" +numbers_hint: "numbers only" +submit_new: "Create Tunnel" +submit_edit: "Save Changes" +form_nav_hint: "TAB: navigate • ENTER: submit • ESC: cancel" + +# Empty states +no_tunnels: "No tunnels configured" +add_tunnel_prompt: "Press 'a' to add a new tunnel" +no_logs: "No logs available" + +# Empty state +welcome_title: "Welcome, Dungeon Master!" +no_tunnels_yet: "You haven't created any tunnels yet." +tunnels_desc: "Tunnels let your players connect to your Foundry world." +create_first: "[ Create First Tunnel ]" +press_a_hint: "Or press 'a' to start" +tip_dashboard: "💡 Tip: Web dashboard at" + +# Settings UI +settings_title: "⚙ Settings" +settings_nav_hint: "↑/↓ navigate • space/enter toggle • esc back" + +# Downloads +downloading: "Downloading..." +download_complete: "Download complete" +download_failed: "Download failed" +download_progress: "{percent}% complete" \ No newline at end of file diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml new file mode 100644 index 0000000..31bfda1 --- /dev/null +++ b/internal/i18n/locales/es.yaml @@ -0,0 +1,143 @@ +# Traducciones en español + +# App +app_name: "Gestor de Túneles Foundry" +app_description: "Gestiona túneles para exponer Foundry VTT" + +# General +ok: "Aceptar" +cancel: "Cancelar" +close: "Cerrar" +save: "Guardar" +delete: "Eliminar" +edit: "Editar" +create: "Crear" +start: "Iniciar" +stop: "Detener" +restart: "Reiniciar" +status: "Estado" +name: "Nombre" +type: "Tipo" +port: "Puerto" +url: "URL" + +# Status +online: "En línea" +offline: "Desconectado" +connecting: "Conectando..." +error: "Error" +timeout: "Tiempo agotado" +starting: "Iniciando..." +stopping: "Deteniendo..." + +# Providers +provider_pinggy: "Pinggy" +provider_serveo: "Serveo" +provider_cloudflared: "Cloudflared" +provider_tunnelmole: "Tunnelmole" +provider_localhostrun: "LocalhostRun" + +# Actions +connect: "Conectar" +disconnect: "Desconectar" +copy_url: "Copiar URL" +open_browser: "Abrir en navegador" +configure: "Configurar" + +# Form labels +tunnel_name: "Nombre del túnel" +select_provider: "Seleccionar proveedor" +local_port: "Puerto local" +ssh_key: "Clave SSH (opcional)" +provider_options: "Opciones del proveedor" + +# Messages +confirm_delete: "¿Estás seguro de que quieres eliminar este túnel?" +confirm_stop: "¿Estás seguro de que quieres detener este túnel?" +copied: "Copiado al portapapeles" +connection_failed: "Conexión fallida" +connection_success: "Conexión exitosa" +tunnel_expired: "Túnel expirado" +tunnel_expiring: "Túnel por expirar" + +# Notifications +notification_tunnel_online: "{name} está en línea" +notification_tunnel_offline: "{name} está desconectado" +notification_tunnel_error: "{name} encontró un error" +notification_tunnel_expiring: "{name} expira en {minutes} minutos" +notification_tunnel_expired: "{name} ha expirado" + +# Settings +settings: "Configuración" +language: "Idioma" +theme: "Tema" +notifications: "Notificaciones" +enable_notifications: "Habilitar notificaciones" +notification_sound: "Sonido de notificación" +expiration_warnings: "Advertencias de expiración" + +# Help +help: "Ayuda" +press_enter: "Presiona Enter para seleccionar" +press_esc: "Presiona Esc para volver" +press_tab: "Presiona Tab para cambiar" +navigation_hint: "↑↓ Navegar • Enter Seleccionar • Esc Volver" + +# Errors +error_loading_config: "Error al cargar la configuración" +error_saving_config: "Error al guardar la configuración" +error_invalid_port: "Número de puerto inválido" +error_invalid_name: "Nombre de túnel inválido" +error_connection_refused: "Conexión rechazada" + +# Labels +connections: "Tus Conexiones" +selected_tunnel: "Túnel Seleccionado" +select_tunnel_details: "Selecciona un túnel para ver detalles" +provider_label: "Proveedor" +port_label: "Puerto" +status_label: "Estado" +start_action: "Iniciar" +stop_action: "Detener" +logs_action: "Logs" +delete_action: "Eliminar" +press_c_copy: "Presiona 'c' para copiar" + +# Form labels +new_tunnel: "✨ Nuevo Túnel" +new_tunnel_desc: "Crea un túnel seguro a tu servicio local" +edit_tunnel: "✏️ Editar Túnel" +edit_tunnel_desc: "Modifica la configuración del túnel" +name_label: "Nombre" +tunnel_name_hint: "identificador del túnel" +provider_hint: "cloudflare | pinggy | serveo" +port_hint: "ej: 3000, 8080" +type_hint: "escribe para entrar" +arrow_hint: "← → para cambiar" +numbers_hint: "solo números" +submit_new: "Crear Túnel" +submit_edit: "Guardar Cambios" +form_nav_hint: "TAB: navegar • ENTER: enviar • ESC: cancelar" + +# Empty states +no_tunnels: "No hay túneles configurados" +add_tunnel_prompt: "Presiona 'a' para agregar un nuevo túnel" +no_logs: "No hay logs disponibles" + +# Empty state +welcome_title: "¡Bienvenido, Dungeon Master!" +no_tunnels_yet: "Aún no has creado ningún túnel." +tunnels_desc: "Los túneles permiten a tus jugadores conectarse a tu mundo de Foundry." +create_first: "[ Crear Primer Túnel ]" +press_a_hint: "O presiona 'a' para comenzar" +tip_dashboard: "💡 Tip: Panel web en" + +# Settings UI +settings_title: "⚙ Configuración" +settings_nav_hint: "↑/↓ navegar • espacio/enter cambiar • esc volver" + +# Downloads +downloading: "Descargando..." +download_complete: "Descarga completa" +download_failed: "Descarga fallida" +download_progress: "{percent}% completado" \ No newline at end of file From ca0f3d534922d0afd5fa2c0acd20a6403badcd21 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:38:44 -0600 Subject: [PATCH 03/54] feat(app): init i18n in New() and add Language to Config --- internal/app/app.go | 14 +++++++++++--- internal/config/config.go | 2 ++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 69a087c..2df1b1a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/notifications" "github.com/sthbryan/ftm/internal/process" "github.com/sthbryan/ftm/internal/providers" @@ -18,16 +19,23 @@ type App struct { Config *config.Config Manager *process.Manager WebServer *web.Server - DownloadProgress chan providers.DownloadProgress + DownloadProgress chan providers.DownloadProgress ExpirationMonitor *notifications.ExpirationMonitor } func New() (*App, error) { + + if err := i18n.Load(); err != nil { + return nil, fmt.Errorf("failed to load translations: %w", err) + } + cfg, err := config.Load() if err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } + i18n.InitFromConfig(cfg) + manager := process.NewManager() app := &App{ @@ -42,8 +50,8 @@ func New() (*App, error) { notifications.SetNotificationsEnabled(cfg.NotificationsStatus == config.NotificationGranted) expConfig := notifications.ExpirationConfig{ - Thresholds: cfg.ExpirationThresholds, - ProviderExpirationMinutes: cfg.ProviderExpirationMinutes, + Thresholds: cfg.ExpirationThresholds, + ProviderExpirationMinutes: cfg.ProviderExpirationMinutes, } app.ExpirationMonitor = notifications.NewExpirationMonitor(expConfig, func(name string, mins int) { if !app.shouldUseNativeNotifications() { diff --git a/internal/config/config.go b/internal/config/config.go index d92cc9a..b97c3d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { Version int `yaml:"version"` Tunnels []TunnelConfig `yaml:"tunnels"` WebPort int `yaml:"web_port,omitempty"` + Language string `yaml:"language"` NotificationsStatus string `yaml:"notifications_status"` NotificationSound bool `yaml:"notification_sound"` @@ -31,6 +32,7 @@ type Config struct { func DefaultConfig() *Config { return &Config{ Version: 1, + Language: "en", Tunnels: []TunnelConfig{}, NotificationsStatus: NotificationPending, NotificationSound: true, From 87dd475a00b90907bda4379b0797841d0863fc7c Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:38:47 -0600 Subject: [PATCH 04/54] feat(ui): add Left/Right keys for language selector --- internal/app/model_types.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/app/model_types.go b/internal/app/model_types.go index fb86613..b055711 100644 --- a/internal/app/model_types.go +++ b/internal/app/model_types.go @@ -37,6 +37,8 @@ const TwoColumnThreshold = 100 type KeyMap struct { Up key.Binding Down key.Binding + Left key.Binding + Right key.Binding Enter key.Binding Toggle key.Binding Logs key.Binding @@ -61,6 +63,14 @@ var DefaultKeys = KeyMap{ key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "prev"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "next"), + ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "toggle"), From 5f83f9f322a27b1d011edd74ad74d2c735eee7ba Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:38:53 -0600 Subject: [PATCH 05/54] =?UTF-8?q?feat(settings):=20implement=20language=20?= =?UTF-8?q?selector=20with=20=E2=86=90/=E2=86=92=20and=20Enter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/keyhandlers_settings.go | 39 +++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/app/keyhandlers_settings.go b/internal/app/keyhandlers_settings.go index 399693e..43d4cd2 100644 --- a/internal/app/keyhandlers_settings.go +++ b/internal/app/keyhandlers_settings.go @@ -6,6 +6,7 @@ import ( "github.com/sthbryan/ftm/internal/app/ui/views" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" ) func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -23,10 +24,22 @@ func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.Keys.Down): - if sv.Focused < 1 { + if sv.Focused < 2 { sv.Focused++ } + case key.Matches(msg, m.Keys.Left): + if sv.Focused == 2 { + sv.Language = cycleLanguage(sv.Language, -1) + i18n.SetLanguage(sv.Language) + } + + case key.Matches(msg, m.Keys.Right): + if sv.Focused == 2 { + sv.Language = cycleLanguage(sv.Language, 1) + i18n.SetLanguage(sv.Language) + } + case key.Matches(msg, m.Keys.Enter), key.Matches(msg, m.Keys.Toggle): m.handleSettingsSelect() @@ -51,13 +64,34 @@ func (m *Model) handleSettingsSelect() { sv.NotificationsEnabled = !sv.NotificationsEnabled case 1: sv.NotificationSound = !sv.NotificationSound + case 2: + + sv.Language = cycleLanguage(sv.Language, 1) + i18n.SetLanguage(sv.Language) } } +func cycleLanguage(current string, dir int) string { + langs := i18n.SupportedLanguages() + for i, lang := range langs { + if lang == current { + next := i + dir + if next < 0 { + next = len(langs) - 1 + } else if next >= len(langs) { + next = 0 + } + return langs[next] + } + } + return langs[0] +} + func (m *Model) openSettings() { m.SettingsView = views.NewSettingsView() m.SettingsView.NotificationsEnabled = m.App.Config.NotificationsStatus == config.NotificationGranted m.SettingsView.NotificationSound = m.App.Config.NotificationSound + m.SettingsView.Language = i18n.GetCurrentLang() m.State = viewSettings } @@ -75,6 +109,9 @@ func (m *Model) saveSettings() { } m.App.Config.NotificationSound = sv.NotificationSound + m.App.Config.Language = sv.Language + + i18n.SetLanguage(sv.Language) m.App.SaveConfig() } From 833c5406b434779cb52f66ea6721e1d29c8a330f Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:38:59 -0600 Subject: [PATCH 06/54] feat(views): add i18n to all TUI views and components --- internal/app/ui/components/detail_panel.go | 21 ++-- internal/app/ui/components/help_bar.go | 16 +-- internal/app/ui/views/empty.go | 13 +-- internal/app/ui/views/form.go | 108 ++++++++------------- internal/app/ui/views/list.go | 5 +- internal/app/ui/views/settings.go | 43 +++++++- 6 files changed, 107 insertions(+), 99 deletions(-) diff --git a/internal/app/ui/components/detail_panel.go b/internal/app/ui/components/detail_panel.go index 75ae59b..4a24032 100644 --- a/internal/app/ui/components/detail_panel.go +++ b/internal/app/ui/components/detail_panel.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" ) type DetailPanel struct { @@ -39,23 +40,23 @@ func (d *DetailPanel) Render() string { labelStyle := lipgloss.NewStyle().Foreground(ui.ThemeDefault.TextDim) textStyle := lipgloss.NewStyle().Foreground(ui.ThemeDefault.Text) - b.WriteString(labelStyle.Render("Provider:")) + b.WriteString(labelStyle.Render(i18n.T("provider_label") + ":")) b.WriteString(" ") - b.WriteString(textStyle.Render(d.Provider)) + b.WriteString(textStyle.Render(i18n.ProviderText(d.Provider))) b.WriteString("\n") - b.WriteString(labelStyle.Render("Local Port:")) + b.WriteString(labelStyle.Render(i18n.T("port_label") + ":")) b.WriteString(" ") b.WriteString(textStyle.Render(fmt.Sprintf(":%d", d.LocalPort))) b.WriteString("\n\n") - b.WriteString(labelStyle.Render("Status:")) + b.WriteString(labelStyle.Render(i18n.T("status_label") + ":")) b.WriteString(" ") b.WriteString(textStyle.Render(StatusLabel(d.StatusState))) b.WriteString("\n\n") if d.StatusState == TunnelStateOnline && d.PublicURL != "" { - b.WriteString(labelStyle.Render("Public URL:")) + b.WriteString(labelStyle.Render("URL:")) b.WriteString("\n") urlBox := lipgloss.NewStyle(). @@ -70,7 +71,7 @@ func (d *DetailPanel) Render() string { copyHint := lipgloss.NewStyle(). Foreground(ui.ThemeDefault.Bronze). - Render("Press 'c' to copy") + Render(i18n.T("press_c_copy")) b.WriteString(copyHint) b.WriteString("\n\n") } @@ -104,13 +105,13 @@ func (d *DetailPanel) actions() string { Padding(0, 2) if isActive { - actions = append(actions, buttonStyle.Render("[t] Stop")) + actions = append(actions, buttonStyle.Render("[t] "+i18n.T("stop_action"))) } else { - actions = append(actions, buttonStyle.Render("[t] Start")) + actions = append(actions, buttonStyle.Render("[t] "+i18n.T("start_action"))) } - actions = append(actions, buttonStyle.Render("[l] Logs")) - actions = append(actions, buttonStyle.Render("[d] Delete")) + actions = append(actions, buttonStyle.Render("[l] "+i18n.T("logs_action"))) + actions = append(actions, buttonStyle.Render("[d] "+i18n.T("delete_action"))) return strings.Join(actions, " ") } diff --git a/internal/app/ui/components/help_bar.go b/internal/app/ui/components/help_bar.go index 0945e66..5b9c119 100644 --- a/internal/app/ui/components/help_bar.go +++ b/internal/app/ui/components/help_bar.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" ) type HelpBar struct{} @@ -15,16 +16,15 @@ func NewHelpBar() *HelpBar { func (h *HelpBar) Render() string { shortcuts := []string{ - "↑/↓ navigate", - "Enter toggle", - "a add", - "e edit", - "d delete", - "s settings", - "l logs", + i18n.T("navigation_hint"), + "a " + i18n.T("create"), + "e " + i18n.T("edit"), + "d " + i18n.T("delete"), + "s " + i18n.T("settings"), + "l " + i18n.T("logs"), "w web", "o config", - "q quit", + "q " + i18n.T("close"), } firstLine := strings.Join(shortcuts[:5], " • ") diff --git a/internal/app/ui/views/empty.go b/internal/app/ui/views/empty.go index b91579b..8dea5c2 100644 --- a/internal/app/ui/views/empty.go +++ b/internal/app/ui/views/empty.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/version" ) @@ -31,29 +32,29 @@ func (e *EmptyState) Render() string { title := lipgloss.NewStyle(). Foreground(gold). Bold(true). - Render("Welcome, Dungeon Master!") + Render(i18n.T("welcome_title")) subtitle := lipgloss.NewStyle(). Foreground(text). - Render("You haven't created any tunnels yet.") + Render(i18n.T("no_tunnels_yet")) desc := lipgloss.NewStyle(). Foreground(textDim). - Render("Tunnels let your players connect to your Foundry world.") + Render(i18n.T("tunnels_desc")) cta := lipgloss.NewStyle(). Background(gold). Bold(true). Padding(0, 2). - Render("[ Create First Tunnel ]") + Render(i18n.T("create_first")) hint := lipgloss.NewStyle(). Foreground(textDim). - Render("Or press 'a' to start") + Render(i18n.T("press_a_hint")) tip := lipgloss.NewStyle(). Foreground(bronze). - Render("💡 Tip: Web dashboard at " + e.Dashboard + " • ws:" + fmt.Sprintf("%d", e.Sessions)) + Render(i18n.T("tip_dashboard") + " " + e.Dashboard + " • ws:" + fmt.Sprintf("%d", e.Sessions)) contentHeight := 12 paddingTop := (e.Height - contentHeight) / 2 diff --git a/internal/app/ui/views/form.go b/internal/app/ui/views/form.go index 5f2fe81..d1c9d6a 100644 --- a/internal/app/ui/views/form.go +++ b/internal/app/ui/views/form.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" ) type FormView struct { @@ -24,15 +25,26 @@ func NewFormView() *FormView { func (f *FormView) Render() string { t := ui.ThemeDefault inputWidth := 25 - labelWidth := 17 totalWidth := labelWidth + 2 + inputWidth - header := "✨ New Tunnel" - subheader := "Create a secure tunnel to your local service" + newTunnelText := i18n.T("new_tunnel") + editTunnelText := i18n.T("edit_tunnel") + newTunnelDesc := i18n.T("new_tunnel_desc") + editTunnelDesc := i18n.T("edit_tunnel_desc") + nameLabel := i18n.T("name_label") + nameHint := i18n.T("tunnel_name_hint") + providerLabel := i18n.T("provider_label") + providerHint := i18n.T("provider_hint") + portLabel := i18n.T("local_port") + portHint := i18n.T("port_hint") + navHint := i18n.T("form_nav_hint") + + header := newTunnelText + subheader := newTunnelDesc if f.IsEditMode { - header = "✏️ Edit Tunnel" - subheader = "Modify your tunnel settings" + header = editTunnelText + subheader = editTunnelDesc } headerStyle := lipgloss.NewStyle(). @@ -44,89 +56,49 @@ func (f *FormView) Render() string { Foreground(t.TextDim). Render(subheader) + if f.Focus == 0 { + nameLabel = "▸ " + nameLabel + nameHint = i18n.T("type_hint") + } + if f.Focus == 1 { + providerLabel = "▸ " + providerLabel + providerHint = i18n.T("arrow_hint") + } + if f.Focus == 2 { + portLabel = "▸ " + portLabel + portHint = i18n.T("numbers_hint") + } + lines := []string{ headerStyle, "", subheaderStyle, "", - f.nameField(t, inputWidth, labelWidth), + f.fieldWithLabel(nameLabel, nameHint, f.Name, 0, t, inputWidth, labelWidth), "", - f.providerField(t, inputWidth, labelWidth), + f.fieldWithLabel(providerLabel, providerHint, f.Provider, 1, t, inputWidth, labelWidth), "", - f.portField(t, inputWidth, labelWidth), + f.fieldWithLabel(portLabel, portHint, f.Port, 2, t, inputWidth, labelWidth), "", f.submitButton(t, inputWidth), "", - lipgloss.NewStyle().Foreground(t.TextDim).Render("TAB: to navigate fields\nENTER: to submit\nESC: to cancel"), + lipgloss.NewStyle().Foreground(t.TextDim).Render(navHint), } content := strings.Join(lines, "\n") return centerBlock(content, f.Width, totalWidth) } -func (f *FormView) nameField(t *ui.Theme, inputWidth, labelWidth int) string { - label := "Name" - hint := "your tunnel identifier" - - if f.Focus == 0 { - label = "▸ Name" - hint = "type to enter" - } - - value := f.Name +func (f *FormView) fieldWithLabel(label, hint, value string, field int, t *ui.Theme, inputWidth, labelWidth int) string { if value == "" { value = "..." } - - labelStyle := f.labelStyle(t, 0, labelWidth) - inputStyle := f.inputStyle(t, 0, inputWidth) - hintStyle := lipgloss.NewStyle().Foreground(t.Bronze) - - return fmt.Sprintf("%s\n%s\n%s", - labelStyle.Render(label+":"), - inputStyle.Render(value), - hintStyle.Render(hint), - ) -} - -func (f *FormView) providerField(t *ui.Theme, inputWidth, labelWidth int) string { - label := "Provider" - value := f.Provider - hint := "cloudflare | ngrok | local" - - if f.Focus == 1 { - label = "▸ Provider" + if field == 1 && f.Focus == 1 { value = "‹ " + value + " ›" - hint = "← → to change" - } - - labelStyle := f.labelStyle(t, 1, labelWidth) - inputStyle := f.inputStyle(t, 1, inputWidth) - hintStyle := lipgloss.NewStyle().Foreground(t.Bronze) - - return fmt.Sprintf("%s\n%s\n%s", - labelStyle.Render(label+":"), - inputStyle.Render(value), - hintStyle.Render(hint), - ) -} - -func (f *FormView) portField(t *ui.Theme, inputWidth, labelWidth int) string { - label := "Local Port" - value := f.Port - hint := "e.g. 3000, 8080" - - if f.Focus == 2 { - label = "▸ Local Port" - hint = "numbers only" - } - - if value == "" { - value = "..." } - labelStyle := f.labelStyle(t, 2, labelWidth) - inputStyle := f.inputStyle(t, 2, inputWidth) + labelStyle := f.labelStyle(t, field, labelWidth) + inputStyle := f.inputStyle(t, field, inputWidth) hintStyle := lipgloss.NewStyle().Foreground(t.Bronze) return fmt.Sprintf("%s\n%s\n%s", @@ -137,9 +109,9 @@ func (f *FormView) portField(t *ui.Theme, inputWidth, labelWidth int) string { } func (f *FormView) submitButton(t *ui.Theme, inputWidth int) string { - btnText := "Create Tunnel" + btnText := i18n.T("submit_new") if f.IsEditMode { - btnText = "Save Changes" + btnText = i18n.T("submit_edit") } btnStyle := lipgloss.NewStyle(). diff --git a/internal/app/ui/views/list.go b/internal/app/ui/views/list.go index b6c6b4c..8705300 100644 --- a/internal/app/ui/views/list.go +++ b/internal/app/ui/views/list.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" "github.com/sthbryan/ftm/internal/app/ui/components" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/version" ) @@ -76,12 +77,12 @@ func (l *ListView) twoColumn() string { leftHeader := lipgloss.NewStyle(). Bold(true). Foreground(text). - Render("Your Connections") + Render(i18n.T("connections")) rightHeader := lipgloss.NewStyle(). Bold(true). Foreground(text). - Render("Selected Tunnel") + Render(i18n.T("selected_tunnel")) b.WriteString(leftHeader) b.WriteString(strings.Repeat(" ", leftWidth-lipgloss.Width(leftHeader)+3)) diff --git a/internal/app/ui/views/settings.go b/internal/app/ui/views/settings.go index a1c4a83..550fcff 100644 --- a/internal/app/ui/views/settings.go +++ b/internal/app/ui/views/settings.go @@ -5,18 +5,21 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" ) type SettingsView struct { Width int NotificationsEnabled bool NotificationSound bool + Language string Focused int } func NewSettingsView() *SettingsView { return &SettingsView{ - Focused: 0, + Focused: 0, + Language: i18n.GetCurrentLang(), } } @@ -27,7 +30,7 @@ func (s *SettingsView) Render() string { header := lipgloss.NewStyle(). Foreground(t.Gold). Bold(true). - Render("⚙ Settings") + Render(i18n.T("settings_title")) b.WriteString(header) b.WriteString("\n") @@ -35,7 +38,7 @@ func (s *SettingsView) Render() string { b.WriteString("\n\n") b.WriteString(s.renderToggle( - "Enable Notifications", + i18n.T("enable_notifications"), s.NotificationsEnabled, s.Focused == 0, t, @@ -43,16 +46,46 @@ func (s *SettingsView) Render() string { b.WriteString("\n") b.WriteString(s.renderToggle( - "Sound Effects", + i18n.T("notification_sound"), s.NotificationSound, s.Focused == 1, t, )) + b.WriteString("\n\n") + + b.WriteString(s.renderLanguageSelector(t)) b.WriteString("\n\n") b.WriteString(lipgloss.NewStyle(). Foreground(t.TextDim). - Render("↑/↓ navigate • space toggle • esc back")) + Render(i18n.T("settings_nav_hint"))) + + return b.String() +} + +func (s *SettingsView) renderLanguageSelector(t *ui.Theme) string { + var b strings.Builder + + label := i18n.T("language") + ":" + + if s.Focused == 2 { + b.WriteString(lipgloss.NewStyle().Foreground(t.Gold).Render("▸ ")) + } else { + b.WriteString(" ") + } + + b.WriteString(label) + b.WriteString(" ") + + for _, lang := range i18n.SupportedLanguages() { + langName := i18n.LanguageName(lang) + if lang == s.Language { + b.WriteString(lipgloss.NewStyle().Foreground(t.Gold).Bold(true).Render("[" + langName + "]")) + } else { + b.WriteString(lipgloss.NewStyle().Foreground(t.TextDim).Render("[" + langName + "]")) + } + b.WriteString(" ") + } return b.String() } From d91a0ef8a1606a800d8eaef925453c2b738ffa12 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:40:17 -0600 Subject: [PATCH 07/54] feat(logs): add i18n to tunnel logs view --- internal/app/ui/views/logs.go | 5 +++-- internal/i18n/locales/en.yaml | 3 +++ internal/i18n/locales/es.yaml | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/app/ui/views/logs.go b/internal/app/ui/views/logs.go index 79173e3..b07394c 100644 --- a/internal/app/ui/views/logs.go +++ b/internal/app/ui/views/logs.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" ) type LogsView struct { @@ -28,7 +29,7 @@ func (l *LogsView) Render() string { header := lipgloss.NewStyle(). Foreground(gold). Bold(true). - Render("📋 Tunnel Logs") + Render(i18n.T("tunnel_logs")) b.WriteString(header) b.WriteString("\n\n") @@ -46,7 +47,7 @@ func (l *LogsView) Render() string { b.WriteString(lipgloss.NewStyle(). Foreground(textDim). - Render("esc/b: back • ↑/↓: scroll")) + Render(i18n.T("logs_nav_hint"))) return b.String() } diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 7af8e63..a23cd6d 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -136,6 +136,9 @@ tip_dashboard: "💡 Tip: Web dashboard at" settings_title: "⚙ Settings" settings_nav_hint: "↑/↓ navigate • space/enter toggle • esc back" +tunnel_logs: "📋 Tunnel Logs" +logs_nav_hint: "esc/b: back • ↑/↓: scroll" + # Downloads downloading: "Downloading..." download_complete: "Download complete" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 31bfda1..cda3bf8 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -136,6 +136,9 @@ tip_dashboard: "💡 Tip: Panel web en" settings_title: "⚙ Configuración" settings_nav_hint: "↑/↓ navegar • espacio/enter cambiar • esc volver" +tunnel_logs: "📋 Logs del Túnel" +logs_nav_hint: "esc/b: volver • ↑/↓: scroll" + # Downloads downloading: "Descargando..." download_complete: "Descarga completa" From bfaced50cb47923ab681d6b7305fd117e2dc9a29 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Thu, 7 May 2026 20:41:09 -0600 Subject: [PATCH 08/54] feat(download): add i18n to downloading view --- internal/app/ui/views/downloading.go | 11 ++++++----- internal/i18n/locales/en.yaml | 5 +++++ internal/i18n/locales/es.yaml | 5 +++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/app/ui/views/downloading.go b/internal/app/ui/views/downloading.go index 27e6f4e..77eeba0 100644 --- a/internal/app/ui/views/downloading.go +++ b/internal/app/ui/views/downloading.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" ) type DownloadingView struct { @@ -30,7 +31,7 @@ func (d *DownloadingView) Render(progressBarView string) string { header := lipgloss.NewStyle(). Foreground(gold). Bold(true). - Render("⬇️ Installing") + Render(i18n.T("installing")) b.WriteString(header) b.WriteString("\n\n") @@ -43,11 +44,11 @@ func (d *DownloadingView) Render(progressBarView string) string { var step string switch { case d.Percent < 90: - step = fmt.Sprintf("Downloading %s...", name) + step = fmt.Sprintf(i18n.T("downloading"), name) case d.Percent < 100: - step = fmt.Sprintf("Installing %s...", name) + step = fmt.Sprintf(i18n.T("installing"), name) default: - step = "Complete!" + step = i18n.T("complete") } stepStyle := lipgloss.NewStyle().Foreground(text) @@ -76,7 +77,7 @@ func (d *DownloadingView) Render(progressBarView string) string { b.WriteString("\n") b.WriteString(lipgloss.NewStyle(). Foreground(textDim). - Render("esc: cancel")) + Render(i18n.T("esc_cancel"))) return b.String() } diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index a23cd6d..36fd2b1 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -139,6 +139,11 @@ settings_nav_hint: "↑/↓ navigate • space/enter toggle • esc back" tunnel_logs: "📋 Tunnel Logs" logs_nav_hint: "esc/b: back • ↑/↓: scroll" +# Downloading +installing: "⬇️ Installing" +complete: "Complete!" +esc_cancel: "esc: cancel" + # Downloads downloading: "Downloading..." download_complete: "Download complete" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index cda3bf8..fff9c92 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -139,6 +139,11 @@ settings_nav_hint: "↑/↓ navegar • espacio/enter cambiar • esc volver tunnel_logs: "📋 Logs del Túnel" logs_nav_hint: "esc/b: volver • ↑/↓: scroll" +# Downloading +installing: "⬇️ Instalando" +complete: "¡Completo!" +esc_cancel: "esc: cancelar" + # Downloads downloading: "Descargando..." download_complete: "Descarga completa" From 67f51ef2e5d6831464f68c8ea20a501dde017f9f Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 15:31:44 -0600 Subject: [PATCH 09/54] feat(list): i18n for success prefix and dashboard label --- internal/app/ui/views/list.go | 6 +++--- internal/i18n/locales/en.yaml | 6 ++++++ internal/i18n/locales/es.yaml | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/app/ui/views/list.go b/internal/app/ui/views/list.go index 8705300..03eda48 100644 --- a/internal/app/ui/views/list.go +++ b/internal/app/ui/views/list.go @@ -126,7 +126,7 @@ func (l *ListView) twoColumn() string { if l.Message != "" { msgStyle := lipgloss.NewStyle().Foreground(gold).Bold(true) - b.WriteString(msgStyle.Render("✓ " + l.Message)) + b.WriteString(msgStyle.Render(i18n.T("success_prefix") + " " + l.Message)) b.WriteString("\n") } @@ -156,7 +156,7 @@ func (l *ListView) singleColumn() string { if l.Dashboard != "" { urlStyle := lipgloss.NewStyle().Foreground(gold) - b.WriteString(urlStyle.Render("🌐 Dashboard: " + l.Dashboard + " (press 'w')")) + b.WriteString(urlStyle.Render(i18n.T("dashboard_label") + " " + l.Dashboard + " " + i18n.T("press_w_hint"))) b.WriteString("\n\n") } @@ -165,7 +165,7 @@ func (l *ListView) singleColumn() string { if l.Message != "" { msgStyle := lipgloss.NewStyle().Foreground(gold).Bold(true) - b.WriteString(msgStyle.Render("✓ " + l.Message)) + b.WriteString(msgStyle.Render(i18n.T("success_prefix") + " " + l.Message)) b.WriteString("\n") } diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 36fd2b1..686a26d 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -139,6 +139,12 @@ settings_nav_hint: "↑/↓ navigate • space/enter toggle • esc back" tunnel_logs: "📋 Tunnel Logs" logs_nav_hint: "esc/b: back • ↑/↓: scroll" +# UI elements +play_indicator: "▶" +success_prefix: "✓" +dashboard_label: "🌐 Dashboard:" +press_w_hint: "(press 'w')" + # Downloading installing: "⬇️ Installing" complete: "Complete!" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index fff9c92..75b63c9 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -139,6 +139,12 @@ settings_nav_hint: "↑/↓ navegar • espacio/enter cambiar • esc volver tunnel_logs: "📋 Logs del Túnel" logs_nav_hint: "esc/b: volver • ↑/↓: scroll" +# UI elements +play_indicator: "▶" +success_prefix: "✓" +dashboard_label: "🌐 Panel:" +press_w_hint: "(presiona 'w')" + # Downloading installing: "⬇️ Instalando" complete: "¡Completo!" From b2602e75e651347936e08f94ded1862f7dcaba1b Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 15:32:11 -0600 Subject: [PATCH 10/54] fix(detail): add i18n for URL and Error labels --- internal/app/ui/components/detail_panel.go | 4 ++-- internal/i18n/locales/en.yaml | 2 ++ internal/i18n/locales/es.yaml | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/app/ui/components/detail_panel.go b/internal/app/ui/components/detail_panel.go index 4a24032..f750862 100644 --- a/internal/app/ui/components/detail_panel.go +++ b/internal/app/ui/components/detail_panel.go @@ -56,7 +56,7 @@ func (d *DetailPanel) Render() string { b.WriteString("\n\n") if d.StatusState == TunnelStateOnline && d.PublicURL != "" { - b.WriteString(labelStyle.Render("URL:")) + b.WriteString(labelStyle.Render(i18n.T("url_label"))) b.WriteString("\n") urlBox := lipgloss.NewStyle(). @@ -77,7 +77,7 @@ func (d *DetailPanel) Render() string { } if d.ErrorMsg != "" { - b.WriteString(labelStyle.Render("Error:")) + b.WriteString(labelStyle.Render(i18n.T("error_label"))) b.WriteString("\n") errorBox := lipgloss.NewStyle(). diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 686a26d..4bdde1b 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -102,6 +102,8 @@ stop_action: "Stop" logs_action: "Logs" delete_action: "Delete" press_c_copy: "Press 'c' to copy" +error_label: "Error" +url_label: "URL" # Form new_tunnel: "✨ New Tunnel" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 75b63c9..8116b0e 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -102,6 +102,8 @@ stop_action: "Detener" logs_action: "Logs" delete_action: "Eliminar" press_c_copy: "Presiona 'c' para copiar" +error_label: "Error" +url_label: "URL" # Form labels new_tunnel: "✨ Nuevo Túnel" From 824a5bcb44780fc18b3c3af478685061b501ec2b Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 15:32:23 -0600 Subject: [PATCH 11/54] fix(list): i18n for select tunnel details placeholder --- internal/app/ui/views/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/ui/views/list.go b/internal/app/ui/views/list.go index 03eda48..fce2877 100644 --- a/internal/app/ui/views/list.go +++ b/internal/app/ui/views/list.go @@ -201,7 +201,7 @@ func (l *ListView) renderDetailPanel(width int) string { if l.Cursor < 0 || l.Cursor >= len(l.Items) { return lipgloss.NewStyle(). Foreground(ui.ThemeDefault.TextDim). - Render("Select a tunnel to view details") + Render(i18n.T("select_tunnel_details")) } item := l.Items[l.Cursor] From 3e0087e7811411400adad1bd07e7caa65a73fd3f Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 15:38:05 -0600 Subject: [PATCH 12/54] feat(i18n): expose translations API for web frontend --- internal/i18n/i18n.go | 46 +++++++++++++++++++++++++++++++ internal/web/handlers.go | 4 +++ internal/web/handlers_settings.go | 27 ++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 6dced17..0bccfda 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -235,3 +235,49 @@ func AddFallback(lang string) { } } } + +func TranslationsMap() map[string]map[string]string { + store.mu.RLock() + defer store.mu.RUnlock() + + result := make(map[string]map[string]string) + for lang, trans := range store.translations { + result[lang] = trans + } + return result +} + +func GetTranslations(lang string) map[string]string { + store.mu.RLock() + defer store.mu.RUnlock() + + if trans, ok := store.translations[lang]; ok { + return trans + } + if trans, ok := store.translations[DefaultLang]; ok { + return trans + } + return nil +} + +func GetCurrentTranslations() map[string]string { + return GetTranslations(currentLang) +} + +func CurrentLanguage() string { + return currentLang +} + +func ChangeLanguage(lang string) { + store.mu.RLock() + _, ok := store.translations[lang] + store.mu.RUnlock() + + if ok { + currentLang = lang + } +} + +func AvailableLanguages() []string { + return SupportedLanguages() +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 647470a..00e5b57 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -47,6 +47,10 @@ func (h *Handlers) Route(w http.ResponseWriter, r *http.Request) { h.handleSettings(w, r) case path == "/api/providers": h.handleProviders(w) + case path == "/api/i18n": + h.handleI18n(w, r) + case path == "/api/i18n/current": + h.handleI18nCurrent(w, r) case path == "/api/detect-port": h.handleDetectPort(w) case strings.HasPrefix(path, "/api/tunnels/"): diff --git a/internal/web/handlers_settings.go b/internal/web/handlers_settings.go index f945ea0..ea99f5e 100644 --- a/internal/web/handlers_settings.go +++ b/internal/web/handlers_settings.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/notifications" ) @@ -24,6 +25,7 @@ func (h *Handlers) handleGetSettings(w http.ResponseWriter) { json.NewEncoder(w).Encode(map[string]interface{}{ "notifications_enabled": h.config.NotificationsStatus, "notification_sound": h.config.NotificationSound, + "language": h.config.Language, }) } @@ -31,6 +33,7 @@ func (h *Handlers) handlePatchSettings(w http.ResponseWriter, r *http.Request) { var req struct { NotificationsEnabled *string `json:"notifications_enabled,omitempty"` NotificationSound *bool `json:"notification_sound,omitempty"` + Language *string `json:"language,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -46,6 +49,11 @@ func (h *Handlers) handlePatchSettings(w http.ResponseWriter, r *http.Request) { h.config.NotificationSound = *req.NotificationSound } + if req.Language != nil { + h.config.Language = *req.Language + i18n.ChangeLanguage(*req.Language) + } + notifications.SetNotificationsEnabled(h.config.NotificationsStatus == config.NotificationGranted) notifications.SetSoundEnabled(h.config.NotificationSound) if err := h.config.Save(); err != nil { @@ -57,5 +65,24 @@ func (h *Handlers) handlePatchSettings(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{ "notifications_enabled": h.config.NotificationsStatus, "notification_sound": h.config.NotificationSound, + "language": h.config.Language, + }) +} + +func (h *Handlers) handleI18n(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + translations := i18n.TranslationsMap() + json.NewEncoder(w).Encode(map[string]interface{}{ + "translations": translations, + "current": i18n.CurrentLanguage(), + "available": i18n.AvailableLanguages(), + }) +} + +func (h *Handlers) handleI18nCurrent(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "translations": i18n.GetCurrentTranslations(), + "language": i18n.CurrentLanguage(), }) } From 52b385e4eb10dbb714a6167f9659d657b2f4b299 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 15:38:14 -0600 Subject: [PATCH 13/54] feat(i18n): add i18n store for Svelte frontend --- web-svelte/src/lib/i18n/index.ts | 105 +++++++++++++++++++++++++++ web-svelte/src/routes/+layout.svelte | 4 +- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 web-svelte/src/lib/i18n/index.ts diff --git a/web-svelte/src/lib/i18n/index.ts b/web-svelte/src/lib/i18n/index.ts new file mode 100644 index 0000000..95430c7 --- /dev/null +++ b/web-svelte/src/lib/i18n/index.ts @@ -0,0 +1,105 @@ +import { writable, derived, get } from 'svelte/store'; + +export interface Translations { + [key: string]: string; +} + +interface I18nState { + translations: Translations; + language: string; + available: string[]; + loading: boolean; +} + +function createI18nStore() { + const { subscribe, set, update } = writable({ + translations: {}, + language: 'en', + available: ['en', 'es'], + loading: true, + }); + + return { + subscribe, + + async init() { + try { + const res = await fetch('/api/i18n'); + const data = await res.json(); + + update(state => ({ + ...state, + translations: data.translations[data.current] || {}, + language: data.current, + available: data.available, + loading: false, + })); + } catch (e) { + console.error('Failed to load i18n:', e); + update(state => ({ ...state, loading: false })); + } + }, + + async setLanguage(lang: string) { + try { + const res = await fetch('/api/i18n/current'); + const data = await res.json(); + + + await fetch('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language: lang }), + }); + + update(state => ({ + ...state, + translations: data.translations || {}, + language: lang, + })); + } catch (e) { + console.error('Failed to set language:', e); + } + }, + + refresh() { + return this.init(); + }, + }; +} + +export const i18n = createI18nStore(); + + +export function t(key: string, params?: Record): string { + const state = get(i18n); + let text = state.translations[key] || key; + + if (params) { + Object.entries(params).forEach(([k, v]) => { + text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + }); + } + + return text; +} + + +export const translations = derived(i18n, $i18n => $i18n.translations); +export const currentLanguage = derived(i18n, $i18n => $i18n.language); +export const availableLanguages = derived(i18n, $i18n => $i18n.available); + + +export const translate = derived(i18n, ($i18n) => { + return (key: string, params?: Record): string => { + let text = $i18n.translations[key] || key; + + if (params) { + Object.entries(params).forEach(([k, v]) => { + text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + }); + } + + return text; + }; +}); \ No newline at end of file diff --git a/web-svelte/src/routes/+layout.svelte b/web-svelte/src/routes/+layout.svelte index 3a1bc3d..13675a3 100644 --- a/web-svelte/src/routes/+layout.svelte +++ b/web-svelte/src/routes/+layout.svelte @@ -2,13 +2,15 @@ import { onDestroy, onMount } from "svelte"; import Toasts from "$lib/components/Toasts.svelte"; import { subscribeWsMessages } from "$lib/api/ws"; + import { i18n } from "$lib/i18n"; import "../styles/app.css"; let unsubscribeWs: (() => void) | null = null; - onMount(() => { + onMount(async () => { unsubscribeWs = subscribeWsMessages(() => {}); + await i18n.init(); }); onDestroy(() => { From 2937722cada9df60bf57b378646388d59f2d4b77 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 15:39:57 -0600 Subject: [PATCH 14/54] fix(build): add bun install before build --- scripts/build-web-assets.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/build-web-assets.sh b/scripts/build-web-assets.sh index b07450c..3f1caed 100755 --- a/scripts/build-web-assets.sh +++ b/scripts/build-web-assets.sh @@ -7,6 +7,7 @@ DESKTOP_DIST_DIR="$ROOT_DIR/desktop/frontend/dist" STATIC_DIR="$ROOT_DIR/internal/web/static" cd "$WEB_DIR" +bun install bun run build rm -rf "$DESKTOP_DIST_DIR" From b9cc7f810e4da59c62388f443274f05cf5a7fb37 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 15:46:00 -0600 Subject: [PATCH 15/54] feat(web): add language selector in settings with i18n --- internal/i18n/locales/en.yaml | 10 +- internal/i18n/locales/es.yaml | 10 +- web-svelte/src/lib/api/endpoints/settings.ts | 1 + web-svelte/src/lib/stores/settings.svelte.ts | 5 +- web-svelte/src/routes/settings/+page.svelte | 103 +++++++++++++++---- 5 files changed, 104 insertions(+), 25 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 4bdde1b..88b00d6 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -156,4 +156,12 @@ esc_cancel: "esc: cancel" downloading: "Downloading..." download_complete: "Download complete" download_failed: "Download failed" -download_progress: "{percent}% complete" \ No newline at end of file +download_progress: "{percent}% complete" + +# Web settings +web_settings_title: "Settings" +notifications_section: "Desktop Notifications" +enable_notifications_web: "Enable Notifications" +sound_effects: "Sound Effects" +appearance_section: "Appearance" +language_section: "Language" \ No newline at end of file diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 8116b0e..37a8ad0 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -156,4 +156,12 @@ esc_cancel: "esc: cancelar" downloading: "Descargando..." download_complete: "Descarga completa" download_failed: "Descarga fallida" -download_progress: "{percent}% completado" \ No newline at end of file +download_progress: "{percent}% completado" + +# Web settings +web_settings_title: "Configuración" +notifications_section: "Notificaciones de Escritorio" +enable_notifications_web: "Habilitar Notificaciones" +sound_effects: "Efectos de Sonido" +appearance_section: "Apariencia" +language_section: "Idioma" \ No newline at end of file diff --git a/web-svelte/src/lib/api/endpoints/settings.ts b/web-svelte/src/lib/api/endpoints/settings.ts index a98fae2..7946c58 100644 --- a/web-svelte/src/lib/api/endpoints/settings.ts +++ b/web-svelte/src/lib/api/endpoints/settings.ts @@ -3,6 +3,7 @@ import { api } from '../client'; export interface Settings { notifications_enabled: "granted" | "pending" | "rejected"; notification_sound: boolean; + language: string; } export const settingsApi = { diff --git a/web-svelte/src/lib/stores/settings.svelte.ts b/web-svelte/src/lib/stores/settings.svelte.ts index 5ea29e4..e2bd35a 100644 --- a/web-svelte/src/lib/stores/settings.svelte.ts +++ b/web-svelte/src/lib/stores/settings.svelte.ts @@ -1,9 +1,10 @@ -import { settingsApi, type Settings } from '$lib/api'; +import { Settings, settingsApi } from '../api'; import { useNotifications } from './notification.svelte'; let settings = $state({ notifications_enabled: "pending", - notification_sound: true + notification_sound: true, + language: "en" }); let loaded = $state(false); diff --git a/web-svelte/src/routes/settings/+page.svelte b/web-svelte/src/routes/settings/+page.svelte index 531ae70..25bcec3 100644 --- a/web-svelte/src/routes/settings/+page.svelte +++ b/web-svelte/src/routes/settings/+page.svelte @@ -1,23 +1,39 @@
- Foundry Tunnel Manager + {t('app_name')}
-

Foundry Tunnel Manager

-

Share your world with players everywhere

+

{t('app_name')}

+

{t('app_tagline')}

@@ -21,8 +23,8 @@ "p-2 rounded-lg transition-colors", isSettings ? "bg-primary/20 text-primary" : "hover:bg-secondary" )} - aria-label="Settings" + aria-label={t('settings')} > -
+ \ No newline at end of file From c38508588f3dd66c92a069985fe76360f6827e32 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 16:20:25 -0600 Subject: [PATCH 21/54] feat(i18n): add i18n in NewConnection --- internal/i18n/locales/en.yaml | 17 +++--- internal/i18n/locales/es.yaml | 21 +++++--- .../src/lib/components/NewConnection.svelte | 53 ++++++++----------- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 88b00d6..c2e490b 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -1,5 +1,3 @@ -# English translations - # App app_name: "Foundry Tunnel Manager" app_description: "Manage tunnels to expose Foundry VTT" @@ -46,10 +44,9 @@ configure: "Configure" # Form labels tunnel_name: "Tunnel Name" +tunnel_name_hint: "ex. Strahd's Castle" select_provider: "Select Provider" -local_port: "Local Port" -ssh_key: "SSH Key (optional)" -provider_options: "Provider Options" +local_port: "Local" # Messages confirm_delete: "Are you sure you want to delete this tunnel?" @@ -164,4 +161,12 @@ notifications_section: "Desktop Notifications" enable_notifications_web: "Enable Notifications" sound_effects: "Sound Effects" appearance_section: "Appearance" -language_section: "Language" \ No newline at end of file +language_section: "Language" + +# Web Home +app_tagline: "Share your world with players everywhere" +new_connection: "New Connection" +select: "Select" +create_connection: "Create Connection" +connection_created: "Connection \"{name}\" created" +connection_create_failed: "Failed to create connection: {error}" \ No newline at end of file diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 37a8ad0..b030e24 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -1,7 +1,5 @@ -# Traducciones en español - # App -app_name: "Gestor de Túneles Foundry" +app_name: "Foundry Tunnel Manager" app_description: "Gestiona túneles para exponer Foundry VTT" # General @@ -46,10 +44,9 @@ configure: "Configurar" # Form labels tunnel_name: "Nombre del túnel" +tunnel_name_hint: "ej. mi partida de D&D" select_provider: "Seleccionar proveedor" -local_port: "Puerto local" -ssh_key: "Clave SSH (opcional)" -provider_options: "Opciones del proveedor" +local_port: "Puerto" # Messages confirm_delete: "¿Estás seguro de que quieres eliminar este túnel?" @@ -105,7 +102,7 @@ press_c_copy: "Presiona 'c' para copiar" error_label: "Error" url_label: "URL" -# Form labels +# Form new_tunnel: "✨ Nuevo Túnel" new_tunnel_desc: "Crea un túnel seguro a tu servicio local" edit_tunnel: "✏️ Editar Túnel" @@ -164,4 +161,12 @@ notifications_section: "Notificaciones de Escritorio" enable_notifications_web: "Habilitar Notificaciones" sound_effects: "Efectos de Sonido" appearance_section: "Apariencia" -language_section: "Idioma" \ No newline at end of file +language_section: "Idioma" + +# Web Home +app_tagline: "Comparte tu mundo con jugadores en todas partes" +new_connection: "Nueva Conexión" +select: "Seleccionar" +create_connection: "Crear Conexión" +connection_created: "Conexión \"{name}\" creada" +connection_create_failed: "Error al crear conexión: {error}" \ No newline at end of file diff --git a/web-svelte/src/lib/components/NewConnection.svelte b/web-svelte/src/lib/components/NewConnection.svelte index 81d7b48..2b249a8 100644 --- a/web-svelte/src/lib/components/NewConnection.svelte +++ b/web-svelte/src/lib/components/NewConnection.svelte @@ -5,6 +5,7 @@ import { useProviders, detectPort } from "$lib/stores/providers.svelte"; import { useToast } from "$lib/stores/toast.svelte"; import { useTunnels } from "$lib/stores/tunnels.svelte"; + import { translate } from "$lib/i18n"; import Button from "./Button.svelte"; import Dropdown from "./Dropdown.svelte"; import type { DropdownOption } from "$lib/types"; @@ -12,6 +13,7 @@ const store = useTunnels(); const toast = useToast(); const providerStore = useProviders(); + let t = $derived($translate); let sectionEl: HTMLElement | undefined = $state(); let headerEl: HTMLElement | undefined = $state(); @@ -72,9 +74,9 @@ provider: "cloudflared", localPort: detectedPort, }; - toast.success(`Connection "${name}" created`); + toast.success(t("connection_created", { name })); } catch (err) { - toast.error(`Failed to create connection: ${(err as Error).message}`); + toast.error(t("connection_create_failed", { error: (err as Error).message })); } } @@ -85,25 +87,21 @@ class="rounded-xl p-5 bg-card border border-border" >
-

- New Connection +

+ {t("new_connection")}

- +
- +
- +
- +
- + \ No newline at end of file From 4c7fe2e66f1380fd3b527f7c14719b5e26e41c3c Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 16:23:39 -0600 Subject: [PATCH 22/54] feat(i18n): add translations to NewConnection and ConnectionsPanel --- internal/i18n/locales/en.yaml | 4 ++-- internal/i18n/locales/es.yaml | 4 ++-- .../src/lib/components/ConnectionsPanel.svelte | 12 +++++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index c2e490b..5312f31 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -12,6 +12,7 @@ edit: "Edit" create: "Create" start: "Start" stop: "Stop" +loading: "Loading..." restart: "Restart" status: "Status" name: "Name" @@ -44,7 +45,6 @@ configure: "Configure" # Form labels tunnel_name: "Tunnel Name" -tunnel_name_hint: "ex. Strahd's Castle" select_provider: "Select Provider" local_port: "Local" @@ -108,7 +108,7 @@ new_tunnel_desc: "Create a secure tunnel to your local service" edit_tunnel: "✏️ Edit Tunnel" edit_tunnel_desc: "Modify your tunnel settings" name_label: "Name" -tunnel_name_hint: "your tunnel identifier" +tunnel_name_hint: "ex. Strahd's Castle" provider_hint: "cloudflare | pinggy | serveo" port_hint: "e.g. 3000, 8080" type_hint: "type to enter" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index b030e24..ff39188 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -12,6 +12,7 @@ edit: "Editar" create: "Crear" start: "Iniciar" stop: "Detener" +loading: "Cargando..." restart: "Reiniciar" status: "Estado" name: "Nombre" @@ -44,7 +45,6 @@ configure: "Configurar" # Form labels tunnel_name: "Nombre del túnel" -tunnel_name_hint: "ej. mi partida de D&D" select_provider: "Seleccionar proveedor" local_port: "Puerto" @@ -108,7 +108,7 @@ new_tunnel_desc: "Crea un túnel seguro a tu servicio local" edit_tunnel: "✏️ Editar Túnel" edit_tunnel_desc: "Modifica la configuración del túnel" name_label: "Nombre" -tunnel_name_hint: "identificador del túnel" +tunnel_name_hint: "ej. Las Ruinas de Undermountain" provider_hint: "cloudflare | pinggy | serveo" port_hint: "ej: 3000, 8080" type_hint: "escribe para entrar" diff --git a/web-svelte/src/lib/components/ConnectionsPanel.svelte b/web-svelte/src/lib/components/ConnectionsPanel.svelte index 21c90f4..444aa93 100644 --- a/web-svelte/src/lib/components/ConnectionsPanel.svelte +++ b/web-svelte/src/lib/components/ConnectionsPanel.svelte @@ -3,6 +3,7 @@ import { animate, spring } from "motion"; import { onMount } from "svelte"; import { useTunnels } from "$lib/stores/tunnels.svelte"; + import { translate } from "$lib/i18n"; import { cn } from "$lib/utils/cn"; import TunnelCard from "./TunnelCard.svelte"; @@ -10,6 +11,7 @@ $props(); const store = useTunnels(); + let t = $derived($translate); let sectionEl: HTMLElement | undefined = $state(); let headerEl: HTMLElement | undefined = $state(); @@ -55,7 +57,7 @@

- Your Connections + {t("connections")}

- Loading connections... + {t("loading")} {:else if store.tunnels.length === 0}

- No connections yet + {t("no_tunnels")}

- Create your first tunnel to share your Foundry VTT world with players. + {t("tunnels_desc")}

{:else} @@ -105,4 +107,4 @@ {/if} - + \ No newline at end of file From 82f529dd95611d3190f437748793253dcd4d06cf Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 17:49:44 -0600 Subject: [PATCH 23/54] feat(i18n): add TunnelCard translations --- internal/i18n/locales/en.yaml | 7 +++ internal/i18n/locales/es.yaml | 7 +++ .../src/lib/components/TunnelCard.svelte | 62 ++++++++++--------- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 5312f31..ddf0508 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -9,9 +9,11 @@ close: "Close" save: "Save" delete: "Delete" edit: "Edit" +logs: "Logs" create: "Create" start: "Start" stop: "Stop" +wait: "Wait..." loading: "Loading..." restart: "Restart" status: "Status" @@ -41,6 +43,7 @@ connect: "Connect" disconnect: "Disconnect" copy_url: "Copy URL" open_browser: "Open in Browser" +live_logs: "Live logs" configure: "Configure" # Form labels @@ -86,6 +89,8 @@ error_saving_config: "Error saving configuration" error_invalid_port: "Invalid port number" error_invalid_name: "Invalid tunnel name" error_connection_refused: "Connection refused" +error_loading_logs: "Failed to load logs" +tunnel_options: "Tunnel options" # Labels connections: "Your Connections" @@ -121,6 +126,8 @@ form_nav_hint: "TAB: navigate • ENTER: submit • ESC: cancel" # Empty states no_tunnels: "No tunnels configured" add_tunnel_prompt: "Press 'a' to add a new tunnel" +logs_action: "Logs" +click_to_copy: "Click to copy" no_logs: "No logs available" # Empty state diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index ff39188..daf65f9 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -9,9 +9,11 @@ close: "Cerrar" save: "Guardar" delete: "Eliminar" edit: "Editar" +logs: "Logs" create: "Crear" start: "Iniciar" stop: "Detener" +wait: "Espera..." loading: "Cargando..." restart: "Reiniciar" status: "Estado" @@ -41,6 +43,7 @@ connect: "Conectar" disconnect: "Desconectar" copy_url: "Copiar URL" open_browser: "Abrir en navegador" +live_logs: "Logs en vivo" configure: "Configurar" # Form labels @@ -86,6 +89,8 @@ error_saving_config: "Error al guardar la configuración" error_invalid_port: "Número de puerto inválido" error_invalid_name: "Nombre de túnel inválido" error_connection_refused: "Conexión rechazada" +error_loading_logs: "Error al cargar logs" +tunnel_options: "Opciones del túnel" # Labels connections: "Tus Conexiones" @@ -121,6 +126,8 @@ form_nav_hint: "TAB: navegar • ENTER: enviar • ESC: cancelar" # Empty states no_tunnels: "No hay túneles configurados" add_tunnel_prompt: "Presiona 'a' para agregar un nuevo túnel" +logs_action: "Logs" +click_to_copy: "Clic para copiar" no_logs: "No hay logs disponibles" # Empty state diff --git a/web-svelte/src/lib/components/TunnelCard.svelte b/web-svelte/src/lib/components/TunnelCard.svelte index b1876b8..960adaa 100644 --- a/web-svelte/src/lib/components/TunnelCard.svelte +++ b/web-svelte/src/lib/components/TunnelCard.svelte @@ -12,6 +12,7 @@ X, } from "lucide-svelte"; import { logsApi } from "$lib/api"; + import { translate } from "$lib/i18n"; import { cn } from "$lib/utils/cn"; import { formatLogs } from "$lib/utils/logs"; import Button from "./Button.svelte"; @@ -25,7 +26,7 @@ type StatusKey = "running" | "starting" | "installing" | "error" | "stopped"; type StatusColors = { bg: string; text: string; dot: string }; - type StatusInfo = { key: StatusKey; text: string }; + type StatusInfo = { key: StatusKey; textKey: string }; type InstallProgress = { percent: number; step: string }; interface TunnelCardProps { @@ -48,6 +49,7 @@ installProgress = null, }: TunnelCardProps = $props(); + let t = $derived($translate); const dropdownAlign = $derived(index === totalItems - 1 ? "top-left" : "left"); const toast = useToast(); @@ -94,17 +96,17 @@ }; const statusMap: Record = { - online: { key: "running", text: "Online" }, - starting: { key: "starting", text: "Starting..." }, - connecting: { key: "starting", text: "Connecting..." }, - installing: { key: "installing", text: "Installing..." }, - downloading: { key: "installing", text: "Downloading..." }, - need_installing: { key: "stopped", text: "Needs Install" }, - stopping: { key: "starting", text: "Stopping..." }, - stopped: { key: "stopped", text: "Stopped" }, - offline: { key: "stopped", text: "Offline" }, - timeout: { key: "error", text: "Timeout" }, - error: { key: "error", text: "Error" }, + online: { key: "running", textKey: "online" }, + starting: { key: "starting", textKey: "starting" }, + connecting: { key: "starting", textKey: "connecting" }, + installing: { key: "installing", textKey: "installing" }, + downloading: { key: "installing", textKey: "downloading" }, + need_installing: { key: "stopped", textKey: "need_installing" }, + stopping: { key: "starting", textKey: "stopping" }, + stopped: { key: "stopped", textKey: "stopped" }, + offline: { key: "stopped", textKey: "offline" }, + timeout: { key: "error", textKey: "timeout" }, + error: { key: "error", textKey: "error" }, }; const tunnelState = $derived(tunnel.state as TunnelState); @@ -126,7 +128,7 @@ function copyUrl(url: string) { navigator.clipboard.writeText(url); - toast.info("URL copied to clipboard"); + toast.info(t("copied")); } function closeLogs() { @@ -154,7 +156,7 @@ loadingLogs = false; }) .catch(() => { - logs = "Failed to load logs"; + logs = t("error_loading_logs"); loadingLogs = false; }); @@ -183,16 +185,18 @@ } const dropdownOptions: DropdownOption[] = $derived([ - { label: "Edit", action: "edit", icon: Pencil, disabled: isRunning }, - { label: "Logs", action: "logs", icon: FileText }, + { label: t("edit"), action: "edit", icon: Pencil, disabled: isRunning }, + { label: t("logs"), action: "logs", icon: FileText }, { label: "separator", action: "separator" }, - { label: "Delete", action: "delete", icon: Trash2, danger: true }, + { label: t("delete"), action: "delete", icon: Trash2, danger: true }, ]); const installPercent = $derived( Math.trunc((installProgress?.percent ?? 0) * 100) / 100, ); - const installStep = $derived(installProgress?.step ?? "Installing..."); + const installStep = $derived(installProgress?.step ?? t("installing")); + + const providerLabel = $derived(providerNames[tunnel.provider] || tunnel.provider);
- {providerNames[tunnel.provider] || tunnel.provider} — Port {tunnel.port} + {providerLabel} — {t("port")} {tunnel.port}
- {statusInfo.text} + {t(statusInfo.textKey)} {#if tunnelState === "installing" && installProgress} {installPercent}% {/if} @@ -246,7 +250,7 @@ onclick={() => onStop(tunnel.id)} disabled={isInstalling || tunnelState === "stopping"} > - {isInstalling ? "Wait..." : tunnelState === "stopping" ? "Stopping..." : "Stop"} + {isInstalling ? t("wait") : tunnelState === "stopping" ? t("stopping") : t("stop")} {:else} {/if} {#snippet children()} @@ -289,7 +293,7 @@ > Click to copy{t("click_to_copy")} {/if} @@ -315,23 +319,23 @@ >
- Live logs + {t("live_logs")}
{#if loadingLogs}
- Loading logs... + {t("loading")}
{:else}
{logs ||
-              "No logs available"}
+ t("no_logs")} {/if}
- + \ No newline at end of file From 765aaaede8237c58fda686e6162ba1370ca69e4f Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 17:55:45 -0600 Subject: [PATCH 24/54] refactor(i18n): add missing status keys --- internal/i18n/locales/en.yaml | 5 ++++- internal/i18n/locales/es.yaml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index ddf0508..44c4e8f 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -30,6 +30,10 @@ error: "Error" timeout: "Timeout" starting: "Starting..." stopping: "Stopping..." +stopped: "Stopped" +need_installing: "Needs Install" +installing: "Installing..." +downloading: "Downloading..." # Providers provider_pinggy: "Pinggy" @@ -157,7 +161,6 @@ complete: "Complete!" esc_cancel: "esc: cancel" # Downloads -downloading: "Downloading..." download_complete: "Download complete" download_failed: "Download failed" download_progress: "{percent}% complete" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index daf65f9..283d484 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -30,6 +30,10 @@ error: "Error" timeout: "Tiempo agotado" starting: "Iniciando..." stopping: "Deteniendo..." +stopped: "Detenido" +need_installing: "Necesita instalación" +installing: "Instalando..." +downloading: "Descargando..." # Providers provider_pinggy: "Pinggy" @@ -157,7 +161,6 @@ complete: "¡Completo!" esc_cancel: "esc: cancelar" # Downloads -downloading: "Descargando..." download_complete: "Descarga completa" download_failed: "Descarga fallida" download_progress: "{percent}% completado" From d05d1cc00c73320c3fc2f8c1505871517487d146 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 19:12:31 -0600 Subject: [PATCH 25/54] feat(i18n): add DeleteModal translations - Add i18n to DeleteModal.svelte (delete_connection, confirm_delete, cannot_undo) - Add connection_deleted and connection_delete_failed to +page.svelte - Add new translation keys to en.yaml and es.yaml --- internal/i18n/locales/en.yaml | 11 ++++++++--- internal/i18n/locales/es.yaml | 11 ++++++++--- web-svelte/src/lib/components/DeleteModal.svelte | 15 +++++++++------ web-svelte/src/routes/+page.svelte | 9 +++++++-- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 44c4e8f..4890f17 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -22,6 +22,10 @@ type: "Type" port: "Port" url: "URL" +# Modal +delete_connection: "Delete Connection" +cannot_undo: "This action cannot be undone." + # Status online: "Online" offline: "Offline" @@ -56,7 +60,8 @@ select_provider: "Select Provider" local_port: "Local" # Messages -confirm_delete: "Are you sure you want to delete this tunnel?" +confirm_delete: "Are you sure you want to delete \"{name}\"?" +confirm_delete_tunnel: "Are you sure you want to delete \"{name}\"?" confirm_stop: "Are you sure you want to stop this tunnel?" copied: "Copied to clipboard" connection_failed: "Connection failed" @@ -105,7 +110,6 @@ port_label: "Port" status_label: "Status" start_action: "Start" stop_action: "Stop" -logs_action: "Logs" delete_action: "Delete" press_c_copy: "Press 'c' to copy" error_label: "Error" @@ -156,7 +160,6 @@ dashboard_label: "🌐 Dashboard:" press_w_hint: "(press 'w')" # Downloading -installing: "⬇️ Installing" complete: "Complete!" esc_cancel: "esc: cancel" @@ -179,4 +182,6 @@ new_connection: "New Connection" select: "Select" create_connection: "Create Connection" connection_created: "Connection \"{name}\" created" +connection_deleted: "Connection \"{name}\" deleted" +connection_delete_failed: "Failed to delete connection: {error}" connection_create_failed: "Failed to create connection: {error}" \ No newline at end of file diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 283d484..50c7f8b 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -22,6 +22,10 @@ type: "Tipo" port: "Puerto" url: "URL" +# Modal +delete_connection: "Eliminar Conexión" +cannot_undo: "Esta acción no se puede deshacer." + # Status online: "En línea" offline: "Desconectado" @@ -56,7 +60,8 @@ select_provider: "Seleccionar proveedor" local_port: "Puerto" # Messages -confirm_delete: "¿Estás seguro de que quieres eliminar este túnel?" +confirm_delete: "¿Estás seguro de que quieres eliminar \"{name}\"?" +confirm_delete_tunnel: "¿Estás seguro de que quieres eliminar \"{name}\"?" confirm_stop: "¿Estás seguro de que quieres detener este túnel?" copied: "Copiado al portapapeles" connection_failed: "Conexión fallida" @@ -105,7 +110,6 @@ port_label: "Puerto" status_label: "Estado" start_action: "Iniciar" stop_action: "Detener" -logs_action: "Logs" delete_action: "Eliminar" press_c_copy: "Presiona 'c' para copiar" error_label: "Error" @@ -156,7 +160,6 @@ dashboard_label: "🌐 Panel:" press_w_hint: "(presiona 'w')" # Downloading -installing: "⬇️ Instalando" complete: "¡Completo!" esc_cancel: "esc: cancelar" @@ -179,4 +182,6 @@ new_connection: "Nueva Conexión" select: "Seleccionar" create_connection: "Crear Conexión" connection_created: "Conexión \"{name}\" creada" +connection_deleted: "Conexión \"{name}\" eliminada" +connection_delete_failed: "Error al eliminar conexión: {error}" connection_create_failed: "Error al crear conexión: {error}" \ No newline at end of file diff --git a/web-svelte/src/lib/components/DeleteModal.svelte b/web-svelte/src/lib/components/DeleteModal.svelte index ed3b4c9..c51f97f 100644 --- a/web-svelte/src/lib/components/DeleteModal.svelte +++ b/web-svelte/src/lib/components/DeleteModal.svelte @@ -2,6 +2,7 @@ import { X, Trash2 } from "lucide-svelte"; import { animate } from "motion"; import { cn } from "$lib/utils/cn"; + import { translate } from "$lib/i18n"; import Button from "./Button.svelte"; let { @@ -16,6 +17,8 @@ onCancel: () => void; } = $props(); + let t = $derived($translate); + let modalRef: HTMLDivElement | undefined = $state(); let backdropRef: HTMLDivElement | undefined = $state(); let isAnimatingOut = $state(false); @@ -99,7 +102,7 @@ id="modal-title" class="m-0 text-lg font-semibold text-text-heading flex items-center gap-2" > - Delete Connection + {t("delete_connection")}

- Are you sure you want to delete {name}? + {t("confirm_delete", { name })}

-

This action cannot be undone.

+

{t("cannot_undo")}

Cancel{t("cancel")} {t("delete")}
diff --git a/web-svelte/src/routes/+page.svelte b/web-svelte/src/routes/+page.svelte index a9b067e..bd0daa4 100644 --- a/web-svelte/src/routes/+page.svelte +++ b/web-svelte/src/routes/+page.svelte @@ -13,12 +13,15 @@ import type { Tunnel } from "$lib/types"; import { cn } from "$lib/utils/cn"; + import { translate } from "$lib/i18n"; const store = useTunnels(); const toast = useToast(); const providerStore = useProviders(); const theme = useTheme(); + let t = $derived($translate); + let deleteTunnel: Tunnel | null = $state(null); let editingTunnelId: string | null = $state(null); @@ -53,10 +56,12 @@ try { await store.delete(id); - toast.success(`Connection "${name}" deleted`); + toast.success(t("connection_deleted", { name })); deleteTunnel = null; } catch (err) { - toast.error(`Failed to delete connection: ${(err as Error).message}`); + toast.error( + t("connection_delete_failed", { error: (err as Error).message }), + ); } } From edfb300551e67f5a631926e6ff62526d28ffb477 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 19:27:10 -0600 Subject: [PATCH 26/54] feat(i18n): add i18n to EditConnection toasts --- internal/i18n/locales/en.yaml | 2 ++ internal/i18n/locales/es.yaml | 2 ++ web-svelte/src/lib/components/EditConnection.svelte | 8 ++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 4890f17..94a26e9 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -183,5 +183,7 @@ select: "Select" create_connection: "Create Connection" connection_created: "Connection \"{name}\" created" connection_deleted: "Connection \"{name}\" deleted" +connection_updated: "Connection \"{name}\" updated" connection_delete_failed: "Failed to delete connection: {error}" +connection_update_failed: "Failed to update connection: {error}" connection_create_failed: "Failed to create connection: {error}" \ No newline at end of file diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 50c7f8b..f7be413 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -183,5 +183,7 @@ select: "Seleccionar" create_connection: "Crear Conexión" connection_created: "Conexión \"{name}\" creada" connection_deleted: "Conexión \"{name}\" eliminada" +connection_updated: "Conexión \"{name}\" actualizada" connection_delete_failed: "Error al eliminar conexión: {error}" +connection_update_failed: "Error al actualizar conexión: {error}" connection_create_failed: "Error al crear conexión: {error}" \ No newline at end of file diff --git a/web-svelte/src/lib/components/EditConnection.svelte b/web-svelte/src/lib/components/EditConnection.svelte index e7e80fd..0ff41ed 100644 --- a/web-svelte/src/lib/components/EditConnection.svelte +++ b/web-svelte/src/lib/components/EditConnection.svelte @@ -5,11 +5,13 @@ import { useProviders, detectPort } from "$lib/stores/providers.svelte"; import { useToast } from "$lib/stores/toast.svelte"; import { useTunnels } from "$lib/stores/tunnels.svelte"; + import { translate } from "$lib/i18n"; import Button from "./Button.svelte"; import Dropdown from "./Dropdown.svelte"; import type { DropdownOption } from "$lib/types"; let { tunnelId, onCancel, onSaved } = $props(); + let t = $derived($translate); let sectionEl: HTMLElement | undefined = $state(); let headerEl: HTMLElement | undefined = $state(); @@ -95,10 +97,12 @@ provider: formData.provider, localPort: formData.localPort, }); - toast.success(`Connection "${formData.name}" updated`); + toast.success(t("connection_updated", { name: formData.name })); onSaved?.(); } catch (err) { - toast.error(`Failed to update: ${(err as Error).message}`); + toast.error( + t("connection_update_failed", { error: (err as Error).message }), + ); } } From 40056b5f03321fcaa3b32707d8940e74ff8b7680 Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 19:48:33 -0600 Subject: [PATCH 27/54] feat(i18n): add i18n to EditConnection component --- internal/i18n/locales/en.yaml | 4 ++++ internal/i18n/locales/es.yaml | 4 ++++ .../src/lib/components/EditConnection.svelte | 18 +++++++++--------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 94a26e9..d042b9c 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -116,6 +116,10 @@ error_label: "Error" url_label: "URL" # Form +edit_connection: "Edit Connection" +edit_connection_desc: "Modify your tunnel settings" +connection_name_label: "Connection Name" +name_placeholder: "e.g. My Awesome Tunnel" new_tunnel: "✨ New Tunnel" new_tunnel_desc: "Create a secure tunnel to your local service" edit_tunnel: "✏️ Edit Tunnel" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index f7be413..67590d2 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -116,6 +116,10 @@ error_label: "Error" url_label: "URL" # Form +edit_connection: "Editar Conexión" +edit_connection_desc: "Modifica la configuración del túnel" +connection_name_label: "Nombre de Conexión" +name_placeholder: "ej. Mi Túnel Increíble" new_tunnel: "✨ Nuevo Túnel" new_tunnel_desc: "Crea un túnel seguro a tu servicio local" edit_tunnel: "✏️ Editar Túnel" diff --git a/web-svelte/src/lib/components/EditConnection.svelte b/web-svelte/src/lib/components/EditConnection.svelte index 0ff41ed..dc0c164 100644 --- a/web-svelte/src/lib/components/EditConnection.svelte +++ b/web-svelte/src/lib/components/EditConnection.svelte @@ -120,7 +120,7 @@

- Edit Connection + {t("edit_connection")}

{t("cancel")} {t("save")} From 17f60579d13c5f6e4ac22532313c9c9696dcc52a Mon Sep 17 00:00:00 2001 From: Bryan Villafuerte Date: Fri, 8 May 2026 19:52:01 -0600 Subject: [PATCH 28/54] feat(i18n): add i18n to NotificationPermission --- internal/i18n/locales/en.yaml | 2 ++ internal/i18n/locales/es.yaml | 2 ++ .../lib/components/NotificationPermission.svelte | 13 +++++++------ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index d042b9c..f938146 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -176,6 +176,8 @@ download_progress: "{percent}% complete" web_settings_title: "Settings" notifications_section: "Desktop Notifications" enable_notifications_web: "Enable Notifications" +notifications_prompt: "Get notified when tunnels go online, offline, or are about to expire." +not_now: "Not Now" sound_effects: "Sound Effects" appearance_section: "Appearance" language_section: "Language" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 67590d2..2cca666 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -176,6 +176,8 @@ download_progress: "{percent}% completado" web_settings_title: "Configuración" notifications_section: "Notificaciones de Escritorio" enable_notifications_web: "Habilitar Notificaciones" +notifications_prompt: "Recibe notificaciones cuando los túneles se conecten, desconecten o estén por expirar." +not_now: "Ahora no" sound_effects: "Efectos de Sonido" appearance_section: "Apariencia" language_section: "Idioma" diff --git a/web-svelte/src/lib/components/NotificationPermission.svelte b/web-svelte/src/lib/components/NotificationPermission.svelte index f4bdc78..9753a14 100644 --- a/web-svelte/src/lib/components/NotificationPermission.svelte +++ b/web-svelte/src/lib/components/NotificationPermission.svelte @@ -1,10 +1,11 @@