diff --git a/cmd/ftm/main.go b/cmd/ftm/main.go index 0db4461..6139e5d 100644 --- a/cmd/ftm/main.go +++ b/cmd/ftm/main.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/sthbryan/ftm/internal/app" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/version" ) @@ -16,7 +17,7 @@ var BuildVersion string func doUninstall() { binaryPath, err := exec.LookPath("ftm") if err != nil { - fmt.Println("ftm is not installed or not in PATH") + fmt.Println(i18n.T("uninstall_not_found")) os.Exit(1) } @@ -25,13 +26,13 @@ func doUninstall() { absPath = binaryPath } - fmt.Printf("Removing %s...\n", absPath) + fmt.Println(i18n.TF("uninstall_removing", absPath)) if err := os.Remove(absPath); err != nil { - fmt.Fprintf(os.Stderr, "Error removing binary: %v\n", err) + fmt.Fprintf(os.Stderr, i18n.TF("uninstall_error", err.Error())+"\n") os.Exit(1) } - fmt.Println("✓ ftm has been uninstalled") + fmt.Println(i18n.T("uninstall_success")) } func main() { @@ -45,7 +46,7 @@ func main() { flag.Parse() if *showVersion { - fmt.Printf("Foundry Tunnel Manager v%s\n", version.Version) + fmt.Println(i18n.TF("version_output", version.Version)) os.Exit(0) } @@ -75,18 +76,18 @@ func main() { url := application.WebServer.URL() fmt.Printf("🎲 Foundry Tunnel Manager v%s\n", BuildVersion) - fmt.Printf("🌐 Dashboard running at: %s\n", url) + fmt.Printf(i18n.TF("dashboard_url", url)) if *webOnly { - fmt.Println("\nPress Ctrl+C to stop") + fmt.Print(i18n.T("press_ctrl_c")) application.OpenDashboard() select {} } else if *server { - fmt.Println("\nPress Ctrl+C to stop") + fmt.Print(i18n.T("press_ctrl_c")) select {} } - fmt.Printf("\nPress 'w' in the TUI to open the dashboard\n\n") + fmt.Print(i18n.T("tui_hint")) if err := application.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) 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/app/form.go b/internal/app/form.go index a6ed889..2942324 100644 --- a/internal/app/form.go +++ b/internal/app/form.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" ) func (m *Model) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -111,7 +112,7 @@ func (m *Model) handlePortInput(s string) { func (m *Model) submitForm() { if m.FormValues.Name == "" || m.FormValues.Port == "" { - m.showMessage("Name and Port are required") + m.showMessage(i18n.T("validation_required_fields")) return } @@ -139,7 +140,7 @@ func (m *Model) submitEditForm() { m.App.SaveConfig() m.refreshItems() m.State = viewList - m.showMessage("Tunnel updated!") + m.showMessage(i18n.T("tunnel_updated")) } func (m *Model) submitAddForm() { @@ -156,7 +157,7 @@ func (m *Model) submitAddForm() { m.App.SaveConfig() m.refreshItems() m.State = viewList - m.showMessage("Tunnel added!") + m.showMessage(i18n.T("tunnel_added")) } func parsePort(s string) int { diff --git a/internal/app/keyhandlers_list.go b/internal/app/keyhandlers_list.go index 4d34a9b..1ecff68 100644 --- a/internal/app/keyhandlers_list.go +++ b/internal/app/keyhandlers_list.go @@ -9,6 +9,7 @@ import ( "github.com/sthbryan/ftm/internal/clipboard" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" ) func (m *Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -102,7 +103,7 @@ func (m *Model) startAddForm() { func (m *Model) startEditForm() (tea.Model, tea.Cmd) { if item, ok := m.selectedItem(); ok { if item.Status.State != config.TunnelStateStopped { - m.showMessage("Stop tunnel first to edit") + m.showMessage(i18n.T("error_tunnel_running")) return m, nil } m.State = viewEditForm @@ -127,7 +128,7 @@ func (m *Model) handleListDelete() (tea.Model, tea.Cmd) { if m.Cursor >= len(m.Items) && m.Cursor > 0 { m.Cursor-- } - m.showMessage("Tunnel deleted") + m.showMessage(i18n.T("tunnel_deleted")) } return m, nil } @@ -135,26 +136,26 @@ func (m *Model) handleListDelete() (tea.Model, tea.Cmd) { func (m *Model) copyTunnelURL(item TunnelItem) { if item.Status.PublicURL != "" { clipboard.Write(item.Status.PublicURL) - m.showMessage("Copied URL!") + m.showMessage(i18n.T("url_copied")) return } - m.showMessage("No URL available - start tunnel first") + m.showMessage(i18n.T("error_no_url")) } func (m *Model) openDashboard() { if err := m.App.OpenDashboard(); err != nil { - m.showMessage("Error opening dashboard: " + err.Error()) + m.showMessage(i18n.TF("error_dashboard", err.Error())) return } - m.showMessage("Dashboard opened in browser") + m.showMessage(i18n.T("dashboard_opened")) } func (m *Model) openConfigDir() { if err := m.App.OpenConfigDir(); err != nil { - m.showMessage("Error opening config folder: " + err.Error()) + m.showMessage(i18n.TF("error_config", err.Error())) return } - m.showMessage("Config folder opened") + m.showMessage(i18n.T("config_opened")) } func (m *Model) playBeep() { 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() } diff --git a/internal/app/model.go b/internal/app/model.go index 59ac77e..c37da49 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/providers" ) @@ -67,7 +68,7 @@ func (m *Model) installProvider(providerType config.Provider) tea.Cmd { if err != nil { return statusUpdateMsg{ tunnelID: "", - status: config.TunnelStatus{ErrorMessage: "Install failed: " + err.Error(), State: config.TunnelStateError}, + status: config.TunnelStatus{ErrorMessage: i18n.TF("error_install_failed", err.Error()), State: config.TunnelStateError}, } } return downloadProgressMsg{Done: true, Percent: 100} diff --git a/internal/app/model_types.go b/internal/app/model_types.go index fb86613..755a55f 100644 --- a/internal/app/model_types.go +++ b/internal/app/model_types.go @@ -11,6 +11,7 @@ import ( "github.com/sthbryan/ftm/internal/app/ui/views" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/providers" ) @@ -37,6 +38,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 +64,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"), @@ -155,14 +166,18 @@ func (i TunnelItem) FilterValue() string { return i.Tunnel.Name } func (i TunnelItem) Title() string { return i.Tunnel.Name } func (i TunnelItem) Description() string { - status := "OFFLINE" + status := i18n.T("status_offline") switch i.Status.State { case config.TunnelStateStarting: - status = "STARTING" + status = i18n.T("status_starting") case config.TunnelStateConnecting: - status = "CONNECTING" + status = i18n.T("status_connecting") case config.TunnelStateOnline: - status = "ONLINE" + status = i18n.T("status_online") + case config.TunnelStateError: + status = i18n.T("status_error") + case config.TunnelStateTimeout: + status = i18n.T("status_timeout") } - return fmt.Sprintf("%s | Port %d | %s", i.Tunnel.Provider, i.Tunnel.LocalPort, status) + return fmt.Sprintf("%s | %s %d | %s", i.Tunnel.Provider, i18n.T("port"), i.Tunnel.LocalPort, status) } diff --git a/internal/app/ui/components/detail_panel.go b/internal/app/ui/components/detail_panel.go index 75ae59b..f750862 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(i18n.T("url_label"))) b.WriteString("\n") urlBox := lipgloss.NewStyle(). @@ -70,13 +71,13 @@ 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") } if d.ErrorMsg != "" { - b.WriteString(labelStyle.Render("Error:")) + b.WriteString(labelStyle.Render(i18n.T("error_label"))) b.WriteString("\n") errorBox := lipgloss.NewStyle(). @@ -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..fab3195 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", - "w web", - "o config", - "q quit", + 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 " + i18n.T("web"), + "o " + i18n.T("config"), + "q " + i18n.T("close"), } firstLine := strings.Join(shortcuts[:5], " • ") diff --git a/internal/app/ui/components/tunnel_item.go b/internal/app/ui/components/tunnel_item.go index a6f1dd3..25bf5e9 100644 --- a/internal/app/ui/components/tunnel_item.go +++ b/internal/app/ui/components/tunnel_item.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthbryan/ftm/internal/app/ui" + "github.com/sthbryan/ftm/internal/i18n" ) type TunnelItem struct { @@ -35,30 +36,30 @@ const ( func StatusBadge(state int) string { switch state { case TunnelStateStarting, TunnelStateConnecting: - return "[...]" + return i18n.T("badge_starting") case TunnelStateOnline: - return "[ON]" + return i18n.T("badge_online") case TunnelStateError, TunnelStateTimeout: - return "[ERR]" + return i18n.T("badge_error") default: - return "[OFF]" + return i18n.T("badge_offline") } } func StatusLabel(state int) string { switch state { case TunnelStateStarting: - return "STARTING" + return i18n.T("status_starting") case TunnelStateConnecting: - return "CONNECTING" + return i18n.T("status_connecting") case TunnelStateOnline: - return "ONLINE" + return i18n.T("status_online") case TunnelStateError: - return "ERROR" + return i18n.T("status_error") case TunnelStateStopped: - return "OFFLINE" + return i18n.T("status_offline") default: - return "OFFLINE" + return i18n.T("status_offline") } } @@ -140,7 +141,7 @@ func (t *TunnelItem) statusText(bgColor lipgloss.Color) string { } func (t *TunnelItem) meta(bgColor lipgloss.Color) string { - meta := fmt.Sprintf("%s :%d", t.Provider, t.LocalPort) + meta := fmt.Sprintf("%s: %d", t.Provider, t.LocalPort) return lipgloss.NewStyle(). Foreground(ui.ThemeDefault.TextDim). diff --git a/internal/app/ui/views/downloading.go b/internal/app/ui/views/downloading.go index 27e6f4e..c693f16 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 = i18n.TF("downloading", name) case d.Percent < 100: - step = fmt.Sprintf("Installing %s...", name) + step = i18n.TF("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/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..c717746 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" ) @@ -60,7 +61,7 @@ func (l *ListView) twoColumn() string { title := lipgloss.NewStyle(). Foreground(gold). Bold(true). - Render("🎲 Foundry Tunnel Manager") + Render(i18n.T("app_name_tui")) versionStr := lipgloss.NewStyle(). Foreground(textDim). Render("v" + version.Version + " ws:" + fmt.Sprintf("%d", l.Sessions)) @@ -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)) @@ -125,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") } @@ -143,7 +144,7 @@ func (l *ListView) singleColumn() string { title := lipgloss.NewStyle(). Foreground(gold). Bold(true). - Render("🎲 Foundry Tunnel Manager") + Render(i18n.T("app_name_tui")) versionStr := lipgloss.NewStyle(). Foreground(textDim). Render("v" + version.Version + " ws:" + fmt.Sprintf("%d", l.Sessions)) @@ -155,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") } @@ -164,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") } @@ -200,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] 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/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() } diff --git a/internal/app/update.go b/internal/app/update.go index 26a3911..1d7aef9 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -3,6 +3,7 @@ package app import ( tea "github.com/charmbracelet/bubbletea" + "github.com/sthbryan/ftm/internal/i18n" "github.com/sthbryan/ftm/internal/providers" ) @@ -54,13 +55,13 @@ func (m *Model) handleDownloadProgress(msg downloadProgressMsg) (tea.Model, tea. for _, item := range m.Items { if ti, ok := item.(TunnelItem); ok && ti.Tunnel.ID == m.PendingTunnel.ID { m.PendingTunnel = nil - m.showMessage("Install complete! Starting tunnel...") + m.showMessage(i18n.T("install_complete")) return m, m.startTunnel(ti) } } m.PendingTunnel = nil } - m.showMessage("Download complete!") + m.showMessage(i18n.T("download_complete")) } return m, m.checkDownloadProgress() } @@ -69,7 +70,7 @@ func (m *Model) handleStatusUpdate(msg statusUpdateMsg) (tea.Model, tea.Cmd) { m.refreshItems() if msg.status.ErrorMessage != "" { m.playBeep() - m.showMessage("Error: " + msg.status.ErrorMessage) + m.showMessage(i18n.TF("error_state", msg.status.ErrorMessage)) } return m, nil } diff --git a/internal/app/view.go b/internal/app/view.go index 15b1617..1448fa4 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -3,11 +3,12 @@ package app import ( "github.com/sthbryan/ftm/internal/app/ui/views" "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/i18n" ) func (m *Model) View() string { if m.Width == 0 || m.Height == 0 { - return "Loading..." + return i18n.T("loading") } switch m.State { @@ -86,17 +87,17 @@ func statusStateIndex(state config.TunnelState) int { func statusMsg(state config.TunnelState) string { switch state { case config.TunnelStateStarting: - return "Starting..." + return i18n.T("starting") case config.TunnelStateConnecting: - return "Connecting..." + return i18n.T("connecting") case config.TunnelStateOnline: - return "Online" + return i18n.T("online") case config.TunnelStateError: - return "Error" + return i18n.T("error") case config.TunnelStateTimeout: - return "Timeout" + return i18n.T("timeout") default: - return "Offline" + return i18n.T("offline") } } 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, 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/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..a39ea43 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,306 @@ +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 + langMu sync.RWMutex +) + +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 { + langMu.RLock() + lang := currentLang + langMu.RUnlock() + return store.T(key, lang) +} + +func TF(key string, args ...interface{}) string { + template := T(key) + for i, arg := range args { + placeholder := fmt.Sprintf("{%d}", i) + template = strings.Replace(template, placeholder, fmt.Sprintf("%v", arg), 1) + } + return template +} + +func TLang(lang, key string) string { + return store.T(key, lang) +} + +func GetCurrentLang() string { + langMu.RLock() + defer langMu.RUnlock() + return currentLang +} + +func SetLanguage(lang string) { + currentLangOnce.Do(func() {}) + store.mu.Lock() + defer store.mu.Unlock() + langMu.Lock() + defer langMu.Unlock() + if _, ok := store.translations[lang]; ok { + currentLang = lang + } +} + +func SetLanguageWithFallback(lang string) { + store.mu.RLock() + _, ok := store.translations[lang] + store.mu.RUnlock() + + langMu.Lock() + defer langMu.Unlock() + 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 + } + } +} + +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 { + langMu.RLock() + defer langMu.RUnlock() + return currentLang +} + +func ChangeLanguage(lang string) { + store.mu.RLock() + _, ok := store.translations[lang] + store.mu.RUnlock() + + if ok { + langMu.Lock() + currentLang = lang + langMu.Unlock() + } +} + +func AvailableLanguages() []string { + return SupportedLanguages() +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml new file mode 100644 index 0000000..7136de2 --- /dev/null +++ b/internal/i18n/locales/en.yaml @@ -0,0 +1,256 @@ +# 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" +logs: "Logs" +create: "Create" +start: "Start" +stop: "Stop" +wait: "Wait..." +loading: "Loading..." +restart: "Restart" +status: "Status" +name: "Name" +type: "Type" +port: "Port" +url: "URL" + +# Modal +delete_connection: "Delete Connection" +cannot_undo: "This action cannot be undone." + +# Status +online: "Online" +offline: "Offline" +connecting: "Connecting..." +error: "Error" +timeout: "Timeout" +starting: "Starting..." +stopping: "Stopping..." +stopped: "Stopped" +need_installing: "Needs Install" +installing: "Installing {0}..." +downloading: "Downloading {0}..." + +# 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" +live_logs: "Live logs" +configure: "Configure" + +# Form labels +tunnel_name: "Tunnel Name" +select_provider: "Select Provider" +local_port: "Local" + +# Messages +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" +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" +error_loading_logs: "Failed to load logs" +tunnel_options: "Tunnel options" + +# 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" +status_starting: "STARTING" +status_connecting: "CONNECTING" +status_online: "ONLINE" +status_error: "ERROR" +status_offline: "OFFLINE" +start_action: "Start" +stop_action: "Stop" +delete_action: "Delete" +press_c_copy: "Press 'c' to copy" +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" +edit_tunnel_desc: "Modify your tunnel settings" +name_label: "Name" +tunnel_name_hint: "ex. Strahd's Castle" +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" +logs_action: "Logs" +click_to_copy: "Click to copy" +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" + +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 +complete: "Complete!" +esc_cancel: "esc: cancel" + +# Downloads +download_complete: "Download complete!" +download_failed: "Download failed!" +download_progress: "{percent}% complete" + +# Web settings +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" +current_theme: "Current theme" +language_section: "Language" +go_back: "Go back" +lang_en: "English" +lang_es: "Español" + +# Dropdown +close_notification: "Close notification" +options: "Options" +disable: "Disable" +enable: "Enable" + +# 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_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}" + +# TUI Status +status_unknown: "Unknown" +status_installing: "INSTALLING" +status_stopping: "STOPPING" + +# TUI Badges +badge_offline: "[OFF]" +badge_starting: "[...]" +badge_online: "[ON]" +badge_error: "[ERR]" + +# TUI Feedback +tunnel_added: "Tunnel added!" +tunnel_updated: "Tunnel updated!" +tunnel_deleted: "Tunnel deleted" +url_copied: "Copied URL!" +install_complete: "Install complete! Starting tunnel..." +dashboard_opened: "Dashboard opened in browser" +config_opened: "Config folder opened" + +# TUI Errors +error_no_url: "No URL available - start tunnel first" +error_install_failed: "Install failed: {0}" +error_tunnel_running: "Stop tunnel first to edit" +error_dashboard: "Error opening dashboard: {0}" +error_config: "Error opening config folder: {0}" +error_state: "Error: {0}" + +# TUI Validation +validation_required_fields: "Name and Port are required" + +# TUI Elements +app_name_tui: "🎲 Foundry Tunnel Manager" +web: "web" +config: "config" + +# CLI +uninstall_not_found: "ftm is not installed or not in PATH" +uninstall_removing: "Removing {0}..." +uninstall_error: "Error removing binary: {0}" +uninstall_success: "✓ ftm has been uninstalled" +version_output: "Foundry Tunnel Manager v{0}" +dashboard_url: "🌐 Dashboard running at: {0}" +press_ctrl_c: "\nPress Ctrl+C to stop" +tui_hint: "Press 'w' in the TUI to open the dashboard" \ 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..7b9028a --- /dev/null +++ b/internal/i18n/locales/es.yaml @@ -0,0 +1,256 @@ +# App +app_name: "Foundry Tunnel Manager" +app_description: "Gestiona túneles para exponer Foundry VTT" + +# General +ok: "Aceptar" +cancel: "Cancelar" +close: "Cerrar" +save: "Guardar" +delete: "Eliminar" +edit: "Editar" +logs: "Logs" +create: "Crear" +start: "Iniciar" +stop: "Detener" +wait: "Espera..." +loading: "Cargando..." +restart: "Reiniciar" +status: "Estado" +name: "Nombre" +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" +connecting: "Conectando..." +error: "Error" +timeout: "Tiempo agotado" +starting: "Iniciando..." +stopping: "Deteniendo..." +stopped: "Detenido" +need_installing: "Necesita instalación" +installing: "Instalando {0}..." +downloading: "Descargando {0}..." + +# 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" +live_logs: "Logs en vivo" +configure: "Configurar" + +# Form labels +tunnel_name: "Nombre del túnel" +select_provider: "Seleccionar proveedor" +local_port: "Puerto" + +# Messages +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" +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" +error_loading_logs: "Error al cargar logs" +tunnel_options: "Opciones del túnel" + +# 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" +status_starting: "INICIANDO" +status_connecting: "CONECTANDO" +status_online: "EN LÍNEA" +status_error: "ERROR" +status_offline: "DESCONECTADO" +start_action: "Iniciar" +stop_action: "Detener" +delete_action: "Eliminar" +press_c_copy: "Presiona 'c' para copiar" +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" +edit_tunnel_desc: "Modifica la configuración del túnel" +name_label: "Nombre" +tunnel_name_hint: "ej. Las Ruinas de Undermountain" +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" +logs_action: "Logs" +click_to_copy: "Clic para copiar" +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" + +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 +complete: "¡Completo!" +esc_cancel: "esc: cancelar" + +# Downloads +download_complete: "¡Descarga completa!" +download_failed: "¡Descarga fallida!" +download_progress: "{percent}% completado" + +# Web settings +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" +current_theme: "Tema actual" +language_section: "Idioma" +go_back: "Volver" +lang_en: "English" +lang_es: "Español" + +# Dropdown +close_notification: "Cerrar notificación" +options: "Opciones" +disable: "Desactivar" +enable: "Activar" + +# 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_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}" + +# TUI Status +status_unknown: "Desconocido" +status_installing: "INSTALANDO" +status_stopping: "DETENIENDO" + +# TUI Badges +badge_offline: "[OFF]" +badge_starting: "[...]" +badge_online: "[ON]" +badge_error: "[ERR]" + +# TUI Feedback +tunnel_added: "¡Túnel agregado!" +tunnel_updated: "¡Túnel actualizado!" +tunnel_deleted: "Túnel eliminado" +url_copied: "¡URL copiada!" +install_complete: "¡Instalación completa! Iniciando túnel..." +dashboard_opened: "Dashboard abierto en el navegador" +config_opened: "Carpeta de configuración abierta" + +# TUI Errors +error_no_url: "No hay URL disponible - inicia el túnel primero" +error_install_failed: "Instalación fallida: {0}" +error_tunnel_running: "Detén el túnel primero para editar" +error_dashboard: "Error al abrir dashboard: {0}" +error_config: "Error al abrir carpeta de configuración: {0}" +error_state: "Error: {0}" + +# TUI Validation +validation_required_fields: "Nombre y Puerto son requeridos" + +# TUI Elements +app_name_tui: "🎲 Foundry Tunnel Manager" +web: "web" +config: "configuración" + +# CLI +uninstall_not_found: "ftm no está instalado o no está en PATH" +uninstall_removing: "Removiendo {0}..." +uninstall_error: "Error al remover binario: {0}" +uninstall_success: "✓ ftm ha sido desinstalado" +version_output: "Foundry Tunnel Manager v{0}" +dashboard_url: "🌐 Dashboard ejecutándose en: {0}" +press_ctrl_c: "\nPresiona Ctrl+C para detener" +tui_hint: "Presiona 'w' en el TUI para abrir el dashboard" \ No newline at end of file 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..59d1508 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,21 @@ func (h *Handlers) handlePatchSettings(w http.ResponseWriter, r *http.Request) { h.config.NotificationSound = *req.NotificationSound } + if req.Language != nil { + validLangs := i18n.AvailableLanguages() + isValid := false + for _, l := range validLangs { + if l == *req.Language { + isValid = true + break + } + } + if isValid { + 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 +75,36 @@ 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) { + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = i18n.CurrentLanguage() + } + + w.Header().Set("Content-Type", "application/json") + + allTranslations := i18n.TranslationsMap() + currentTrans := allTranslations[lang] + if currentTrans == nil { + currentTrans = allTranslations[i18n.DefaultLang] + lang = i18n.DefaultLang + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "translations": currentTrans, + "current": lang, + "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(), }) } diff --git a/scripts/build-web-assets.sh b/scripts/build-web-assets.sh index b07450c..c8407ee 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 --frozen-lockfile bun run build rm -rf "$DESKTOP_DIST_DIR" 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/api/index.ts b/web-svelte/src/lib/api/index.ts index 3cb66bf..3272c44 100644 --- a/web-svelte/src/lib/api/index.ts +++ b/web-svelte/src/lib/api/index.ts @@ -1,3 +1,4 @@ export { api } from './client'; export * from './types'; export * from './endpoints'; +export type { Settings } from './endpoints/settings'; 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 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/lib/components/Dropdown.svelte b/web-svelte/src/lib/components/Dropdown.svelte index 70e972e..16ece28 100644 --- a/web-svelte/src/lib/components/Dropdown.svelte +++ b/web-svelte/src/lib/components/Dropdown.svelte @@ -2,6 +2,7 @@ import { cn } from "$lib/utils/cn"; import { ChevronDown } from "lucide-svelte"; import { animate, spring } from "motion"; + import { translate } from "$lib/i18n"; import type { DropdownOption } from "$lib/types"; import type { Snippet } from "svelte"; @@ -23,12 +24,14 @@ "top-right": "bottom-full mb-1.5 right-auto left-0" }; + let t = $derived($translate); + let { options = [], onSelect, align = "left", - ariaLabel = "Options", - label = "Options", + ariaLabel = t("options"), + label = t("options"), class: className = "", id = "", children, diff --git a/web-svelte/src/lib/components/EditConnection.svelte b/web-svelte/src/lib/components/EditConnection.svelte index e7e80fd..dc0c164 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 }), + ); } } @@ -116,7 +120,7 @@

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

{t("cancel")} {t("save")} diff --git a/web-svelte/src/lib/components/Header.svelte b/web-svelte/src/lib/components/Header.svelte index 81b718f..aec9f49 100644 --- a/web-svelte/src/lib/components/Header.svelte +++ b/web-svelte/src/lib/components/Header.svelte @@ -1,17 +1,19 @@
- 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 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 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 @@
- {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 diff --git a/web-svelte/src/lib/i18n/index.ts b/web-svelte/src/lib/i18n/index.ts new file mode 100644 index 0000000..a9fe86e --- /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 || {}, + 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 { + await fetch('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language: lang }), + }); + + const res = await fetch('/api/i18n?lang=' + lang); + const data = await res.json(); + + update(state => ({ + ...state, + translations: data.translations || {}, + language: data.current, + available: data.available, + })); + } 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/lib/stores/settings.svelte.ts b/web-svelte/src/lib/stores/settings.svelte.ts index 5ea29e4..471d704 100644 --- a/web-svelte/src/lib/stores/settings.svelte.ts +++ b/web-svelte/src/lib/stores/settings.svelte.ts @@ -1,9 +1,11 @@ -import { settingsApi, type Settings } from '$lib/api'; +import type { Settings } from '../api'; +import { 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/+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(() => { 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 }), + ); } } diff --git a/web-svelte/src/routes/settings/+page.svelte b/web-svelte/src/routes/settings/+page.svelte index 531ae70..7134269 100644 --- a/web-svelte/src/routes/settings/+page.svelte +++ b/web-svelte/src/routes/settings/+page.svelte @@ -1,23 +1,39 @@