diff --git a/CHANGELOG.md b/CHANGELOG.md index 73bca72a..f336092f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.12.0] - TBD + +### Added +- Server version manager: download, update, and downgrade Factorio server from UI - Thanks to @BAYUNZIYUE + - Full version list from updater.factorio.com (stable/experimental) + - Async install with WebSocket real-time progress +- Mod sync from save: read mod list from level.dat0 with exact versions - Thanks to @joey00797-cell + - Selective sync with checkboxes + - WebSocket download progress for each mod +- DLC mods (elevated-rails, quality, space-age) detected from mod-list.json + - Grouped in UI with single toggle - Thanks to @joey00797-cell +- i18n internationalization via i18next framework with zh-CN translations - Thanks to @joey00797-cell, @BAYUNZIYUE +- Language switcher dialog (EN / RU / 中文) +- 5-thread concurrent mod downloading with per-worker progress bars +- Upload mod multi-file support with progress +- Mod list auto-refresh every 2s with background-tab skip +- TabControl onActivate support for lazy portal list loading +- Server settings Chinese descriptions toggle (27 items based on Factorio Wiki) +- File size display in mod list +- IP/port memory in Controls page (localStorage) +- Cache mod portal list for 1 hour (~60x speedup) +- Token-based Factorio authentication support + +### Changed +- Factorio version display uniformly prefixed with `>=` +- Mod portal releases compatibility now uses Factorio-aware GEC (same major.minor required) +- DepOp no longer hardcoded to `>=` — reflects actual dependency operator +- Portal "Mod not found" returns 404 JSON instead of 500 error +- File lock timeout increased to 30s to prevent deadlocks +- Docker build cache for go mod download and npm install + +### Fixed +- Factorio 2.1 compatibility: mods with different minor version correctly marked incompatible +- save.go: Factorio 2.0 save header parsing (readFromV2, 192-line complete rewrite) +- mod_modInfo.go: base dependency version override (old `base >= 0.18` no longer overwrites info.json factorio_version: 2.0) +- mod_modInfo.go: dependency parsing bounds check (prevents crash on malformed deps) +- Corrupted mod zip files gracefully skipped instead of crashing FSM +- 401 auto-redirect to login page (replace instead of push state) +- React #31 crash: axios interceptor no longer passes Object to Flash component +- Mod page black screen: portal API calls limited to initial mount (was 153 calls every 2s) +- Auth gate prevents login form flash (authChecked before tab render) +- Shared Factorio auth state restored across all mod tabs (AddMod + LoadMods) — + parent component checks portal login once, children receive via props; + merged Joey's LoadMods rewrite regressed this with local state — fixed via prop drilling +- Updater API stable tag compatibility for 2.1.7 detection +- save.go: fmt.Errorf escaped percent signs fixed (20+ instances) + +### Removed +- removeAll on Docker volume replaced with proper mod directory clear - Thanks to @joey00797-cell + ## [0.11.0] - TBD ### Changed - Configuration environment variables are now uppercase and prefixed with FSM diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..e265eb50 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,80 @@ +# FSM Roadmap + +## ✅ Done + +- [x] Fix mod folder cleanup on Docker volume +- [x] Fix save file parsing for Factorio 2.0 +- [x] Read all mods with exact versions directly from save file +- [x] New endpoint to read mods from save without downloading +- [x] Selective mod sync with checkboxes (select/deselect all) +- [x] Real-time download progress via WebSocket +- [x] DLC mods auto-detected and grouped in UI with single toggle +- [x] DLC mods visible in installed mods list with enable/disable +- [x] Server start blocked while mod sync is in progress +- [x] i18n support (EN/RU/zh-CN) +- [x] Token-based authentication on factorio.com +- [x] Server version manager (download/update/downgrade Factorio from UI) +- [x] 5-thread concurrent mod downloading with progress bars +- [x] Upload mod multi-file support +- [x] Mod portal list caching (1 hour) +- [x] Factorio 2.1 compatibility (GEC version checking) +- [x] Factorio 2.0 save header parser (readFromV2) +- [x] Server settings Chinese descriptions (Factorio Wiki based) +- [x] Auth gate preventing login form flash +- [x] 401 auto-redirect to login page + +## 📂 Worktree 隔离开发 + +``` +主目录: /workspace/projects/factorio-server-manager-joey (当前功能) +参考目录: /workspace/projects/factorio-server-manager-develop (joey/develop) + +新功能: + git worktree add ../factorio-server-manager- -b feat/ + cd ../factorio-server-manager- + # 开发、构建、测试... + # 完成后: git push && git worktree remove ../factorio-server-manager- +``` + +## 🧪 测试流程(每次改动必做) + +修改代码后,必须闭环验证以下步骤: + +1. **构建**: `npm run build && go build` +2. **部署**: `cp -r app/* /home/game/fsm/app/ && cp factorio-server-manager /home/game/fsm/` +3. **重启**: `kill $(pgrep factorio-server); cd /home/game/fsm && setsid ./factorio-server-manager ...` +4. **登录**: Playwright 打开 `/login` → 输入凭据 → 确认跳转到主页 +5. **功能页**: 导航到改动的页面,等待 15 秒 +6. **检查**: 控制台 0 个 JS Error,页面正常渲染 +7. **交互**: 点击/拖拽核心功能,确认不崩溃 + +> 每次提交前跑一遍,不允许「改完就提交」。 + +## 🚧 Planned + +### Mods +- [ ] Auto-resolve mod dependencies when creating a new save +- [ ] Portal link icon next to each mod in the list +- [ ] Row highlight on hover in mod list +- [ ] Batch update all compatible mods for current server version + +### Authentication +- [ ] Show logged-in username on mod portal tab +- [ ] Refresh button for saved credentials +- [ ] Proper FSM login (registration form on first launch) + +### Server +- [ ] Controls page info panel — merge Game Settings into Controls dashboard: + - Remove standalone `/game-settings` page (mostly empty config.ini view) + - Add read-only info section below start/stop controls: + - Factorio version + base mod version + - Data paths (read-data / write-data) + - Installed / compatible / incompatible mod counts + - Save file count + last modified + - (future) uptime, player count +- [ ] Server Status tab refactor: + - Autostart checkbox + - Factorio version dropdown with auto-download + - Server name field +- [ ] Multi-server support +- [ ] Dual logs — Factorio server logs + FSM manager logs diff --git a/decompiled_script.lua b/decompiled_script.lua new file mode 100644 index 00000000..e69de29b diff --git a/docker/Dockerfile-build b/docker/Dockerfile-build index be131bec..3c888667 100644 --- a/docker/Dockerfile-build +++ b/docker/Dockerfile-build @@ -1,23 +1,29 @@ FROM alpine:latest as build - RUN apk add --no-cache git make musl-dev go nodejs npm zip mingw-w64-gcc - ENV GOROOT /usr/lib/go ENV GOPATH /go ENV PATH /go/bin:$PATH ENV FACTORIO_ROOT /go/src/factorio-server-manager -COPY docker/build-release.sh /usr/local/bin/build-release.sh -COPY ./ $FACTORIO_ROOT - RUN mkdir -p ${GOPATH}/bin -RUN chmod u+x /usr/local/bin/build-release.sh - WORKDIR $FACTORIO_ROOT +# Кешируем Go зависимости отдельно +COPY src/go.mod src/go.sum ./src/ +RUN cd src && go mod download + +# Кешируем npm зависимости отдельно +COPY package.json package-lock.json ./ +RUN npm install + +# Копируем остальное +COPY ./ ./ + +COPY docker/build-release.sh /usr/local/bin/build-release.sh +RUN chmod u+x /usr/local/bin/build-release.sh + VOLUME /build VOLUME $FACTORIO_ROOT - RUN ["/usr/local/bin/build-release.sh"] FROM scratch as output diff --git a/docker/build-release.sh b/docker/build-release.sh old mode 100755 new mode 100644 diff --git a/docker/build.sh b/docker/build.sh old mode 100755 new mode 100644 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100755 new mode 100644 diff --git a/docs/add-language-guide.md b/docs/add-language-guide.md new file mode 100644 index 00000000..05a54021 --- /dev/null +++ b/docs/add-language-guide.md @@ -0,0 +1,143 @@ +# 添加新语言指南 + +FSM 前端基于 `react-i18next`,添加新语言只需创建翻译文件并注册即可。 + +## 快速上手(以日语 ja 为例) + +### 第 1 步:创建翻译文件 + +```bash +mkdir -p ui/locales/ja +``` + +复制英文文件作为模板: + +```bash +cp ui/locales/en/*.json ui/locales/ja/ +``` + +### 第 2 步:翻译 + +逐个编辑 `ui/locales/ja/` 下的 9 个 JSON 文件,**只改 value,不改 key**。 + +| 文件 | 示例 key | 英文 | 日文翻译 | +|---|---|---|---| +| `common.json` | `save` | Save | 保存 | +| `common.json` | `cancel` | Cancel | キャンセル | +| `common.json` | `confirm` | Confirm | 確認 | +| `controls.json` | `startServer` | Start Server | サーバー起動 | +| `controls.json` | `RUNNING` | Running | 実行中 | +| `layout.json` | `appTitle` | Factorio Server Manager | Factorio サーバーマネージャー | + +### 第 3 步:注册语言 + +编辑 `ui/i18n.js`,在两个地方添加 ja: + +**位置 1 — import 翻译文件:** + +```js +// 在 zh-CN imports 后面添加 +import jaCommon from './locales/ja/common.json'; +import jaLayout from './locales/ja/layout.json'; +import jaControls from './locales/ja/controls.json'; +import jaMods from './locales/ja/mods.json'; +import jaSaves from './locales/ja/saves.json'; +import jaServerSettings from './locales/ja/serverSettings.json'; +import jaLogs from './locales/ja/logs.json'; +import jaConsole from './locales/ja/console.json'; +import jaUserManagement from './locales/ja/userManagement.json'; +``` + +**位置 2 — 添加到 resources 对象:** + +```js +const resources = { + en: { /* 已有 */ }, + 'zh-CN': { /* 已有 */ }, + ja: { + common: jaCommon, + layout: jaLayout, + controls: jaControls, + mods: jaMods, + saves: jaSaves, + serverSettings: jaServerSettings, + logs: jaLogs, + console: jaConsole, + userManagement: jaUserManagement, + }, +}; +``` + +### 第 4 步:添加到语言切换器 + +编辑 `ui/App/components/Layout.jsx`,在 `` in FSM Administration section: + ```jsx + + ``` + +**Verification:** +- All sidebar strings render correctly +- Language switcher toggles between en/zh-CN immediately +- Language preference persists in localStorage +- `npm run build` succeeds + +--- + +### T7: Controls Page i18n + +**Status:** pending + +**File:** `ui/App/views/Controls.jsx` (MODIFY) + +**Changes:** +- Add `useTranslation('controls')` hook +- Replace panel title, field labels, button texts, error messages with `t()` calls +- Keep dynamic values (bindip, port, savefile) unchanged + +**Verification:** +- All hardcoded English strings replaced +- Dynamic values preserved +- `npm run build` succeeds + +--- + +### T8: Mods Page i18n (7 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/Mods/Mods.jsx` — panel titles, buttons, warning banner +2. `ui/App/views/Mods/components/UploadMod.jsx` — form labels, buttons +3. `ui/App/views/Mods/components/LoadMods.jsx` — form elements, ConfirmDialog, flash +4. `ui/App/views/Mods/components/ModPack.jsx` — ConfirmDialog usage +5. `ui/App/views/Mods/components/CreateModPack.jsx` — button, Modal title, form labels +6. `ui/App/views/Mods/components/AddMod/components/AddModForm.jsx` — form labels, buttons +7. `ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx` — form labels, button, flash +8. `ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx` — Modal title, table headers + +**Verification:** +- All mods pages render with translated strings +- Tab navigation works +- ConfirmDialog shows translated buttons +- `npm run build` succeeds + +--- + +### T9: Saves Page i18n (3 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/Saves/Saves.jsx` — panel titles, table headers, disabled state +2. `ui/App/views/Saves/components/CreateSaveForm.jsx` — form labels, button, error +3. `ui/App/views/Saves/components/UploadSaveForm.jsx` — form label, placeholder, error, button + +**Verification:** +- All save pages render with translated strings +- Delete confirmation shows proper strings +- `npm run build` succeeds + +--- + +### T10: Settings + Console + Logs i18n (4 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/ServerSettings.jsx` — panel title, save button, flash message +2. `ui/App/views/GameSettings.jsx` — panel title +3. `ui/App/views/Console.jsx` — panel title, disabled message, input placeholder +4. `ui/App/views/Logs.jsx` — panel title + +**Verification:** +- All 4 pages render with translated strings +- Flash message uses translated string +- `npm run build` succeeds + +--- + +### T11: User Management + Login + Help i18n (5 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/UserManagement/UserManagment.jsx` — panel titles, table headers +2. `ui/App/views/UserManagement/components/CreateUserForm.jsx` — labels, placeholders, errors, buttons +3. `ui/App/views/UserManagement/components/ChangePasswordForm.jsx` — labels, placeholders, errors, buttons, flash +4. `ui/App/views/Login.jsx` — panel title, form labels, placeholders, errors, buttons, flash +5. `ui/App/views/Help.jsx` — panel title, section headings, body text + +**Verification:** +- All pages render with translated strings +- Flash messages use translated text +- Form validation errors show translated text +- `npm run build` succeeds + +--- + +### T12: Build Verification + +**Status:** pending + +**Command:** +```bash +npm run build +``` + +**Checks:** +- Build exits with code 0 +- No i18n-related warnings +- Output files exist in `app/` directory + +--- + +### T13: Atomic Commit + +**Status:** pending + +**Commit message:** `功能: 添加 FSM 前端国际化支持(zh-CN)` + +**Files staged:** ~30 files (new i18n config + 18 JSON locale files + ~12 modified components + package.json + package-lock.json) + +--- + +## Success Criteria + +1. `npm run build` exits 0 with no i18n-related warnings +2. All files modified/created as specified +3. English locale loads by default +4. Chinese locale auto-detects when `navigator.language = 'zh-CN'` +5. Language switcher toggles between en/zh-CN immediately +6. Language preference persists across page reload +7. Missing zh-CN keys fall back to English gracefully +8. All pages render without console errors in both languages diff --git a/docs/superpowers/plans/2026-06-21-server-version-manager-implementation.md b/docs/superpowers/plans/2026-06-21-server-version-manager-implementation.md new file mode 100644 index 00000000..f4b258da --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-server-version-manager-implementation.md @@ -0,0 +1,523 @@ +# FSM Server Version Manager — Implementation Plan + +**Date:** 2026-06-21 +**Spec:** `docs/superpowers/specs/2026-06-21-server-version-manager-design.md` +**Branch:** `feat/server-version-manager` + +--- + +## Context + +Add a Server Version Manager to FSM that allows operators to view installed version, fetch available versions from the Factorio API, and download/install a selected version — all from the UI. The install requires server-off state, uses existing Mod Portal credentials for auth, and reports download progress via WebSocket. + +## Task Dependency Graph + +| Task | Depends On | Reason | +|------|------------|--------| +| T1: go.mod add xz dependency | None | Foundation — needed for compilation | +| T2: version_manager.go core logic | T1 | Uses xz package for extraction | +| T3: version_manager_test.go | T2 | Tests the core logic | +| T4: version_handler.go API handlers | T2 | Handlers call VersionManager methods | +| T5: routes.go backend API routes | T4 | Must reference handler functions | +| T6: routes.go frontend SPA route | None | Independent of other backend changes | +| T7: version_handler_test.go | T4, T5 | Tests the handler integration | +| T8: en/serverVersion.json | None | Independent of backend | +| T9: zh-CN/serverVersion.json | None | Independent of backend | +| T10: i18n.js registration | T8, T9 | Must import the new locale files | +| T11: server.js + socket.js API/WS | None | Independent of backend | +| T12: ServerVersion.jsx view | T11, T10, T8, T9 | Needs API methods and i18n strings | +| T13: App.jsx route registration | T12 | Must import ServerVersion component | +| T14: Layout.jsx sidebar link + i18n | T13 | Sidebar nav for the new page | +| T15: Build + verify | All | Final integration verification | + +## Parallel Execution Graph + +**Wave 1 (Start immediately — no inter-file dependencies):** +- T1: go.mod add `ulikunitz/xz` +- T8: Create `en/serverVersion.json` +- T9: Create `zh-CN/serverVersion.json` +- T11: Add API methods + WS subscription to server.js/socket.js + +**Wave 2 (After Wave 1):** +- T2: `version_manager.go` — core logic (download, extract, install, progress) +- T6: Frontend SPA route in routes.go + +**Wave 3 (After Wave 2 completes):** +- T4: `version_handler.go` — 3 API handler functions +- T5: Register backend routes in routes.go +- T10: Register i18n namespace in i18n.js + +**Wave 4 (After Wave 3 completes):** +- T3: `version_manager_test.go` +- T7: `version_handler_test.go` + +**Wave 5 (After Wave 4 completes):** +- T12: `ServerVersion.jsx` — full view component with progress bar + +**Wave 6 (After Wave 5 completes):** +- T13: Add route in App.jsx +- T14: Add sidebar link in Layout.jsx + layout locale keys + +**Wave 7 (After Wave 6 completes):** +- T15: Build frontend + backend, verify compilation + +**Critical Path:** T1 → T2 → T4 → T5 → T12 → T13 → T14 → T15 + +## Tasks + +### Task 1: go.mod — Add `ulikunitz/xz` dependency + +**Description**: Add the pure-Go xz decompression library required by `extractTarXz()`. + +**File**: `src/go.mod` + +**Changes**: +- Add `github.com/ulikunitz/xz v0.5.12` to the `require` block +- Run `cd src && go mod tidy` to update `go.sum` + +**Delegation Recommendation**: +- Category: `quick` — single-line dep addition +- Skills: none needed + +**Depends On**: None + +**Acceptance Criteria**: +- `go.sum` updated +- `go build ./...` passes + +--- + +### Task 2: version_manager.go — Core backend logic + +**Description**: New file containing `VersionManager` struct, `GetInstalledVersion()`, `FetchAvailableVersions()`, `DownloadAndInstall()`, `extractTarXz()`, `ProgressReader`, and `reportProgress()`. + +**File**: `src/factorio/version_manager.go` (new) + +**Key types** (per spec §2.3): +- `LatestReleasesResponse`, `LatestRelease`, `AvailableVersion` +- `ProgressReader` — io.Reader wrapper that reports download % to WebSocket room every ~5% delta or at 100% + +**Key functions**: + +| Function | Detail | +|---|---| +| `GetInstalledVersion()` | Runs `config.FactorioBinary --version`, regex-parses output (same pattern as `NewFactorioServer()` in server.go:150-167). Returns `Version`. | +| `FetchAvailableVersions()` | GET `https://factorio.com/api/latest-releases`, parse JSON. Generate candidate list from `2.0.0` up to `latest_experimental` by incrementing patch. Returns `[]AvailableVersion` with stable/experimental flags. | +| `DownloadAndInstall(v Version)` | 1. `credentials.Load()` → fail 403 if none. 2. Construct URL with username+token. 3. HTTP GET, stream to `/tmp/factorio-dl-.tar.xz` with ProgressReader. 4. `extractTarXz()` over `config.FactorioDir`. 5. `defer os.Remove` temp. 6. Verify with GetInstalledVersion(). | +| `extractTarXz(r io.Reader, dest string)` | xz.NewReader → tar.NewReader. For each header: create dirs, write files, set 0755 on `bin/x64/factorio`. | +| `reportProgress(percent, stage, msg)` | Marshal JSON, send to `websocket.WebsocketHub.GetRoom("server_version").Send()` | + +**Helper**: `IsUpdateAvailable(installed, latestStable Version) bool` — uses existing `Greater()`. + +**WS Event format** (per spec §6.1): +```json +{"room": "server_version", "event": "install_progress", "data": {"percent": 45, "stage": "download", "message": "45.2 MB / 102.4 MB"}} +``` + +**Delegation Recommendation**: +- Category: `deep` — moderately complex Go with HTTP, xz, websocket integration +- Skills: none needed beyond system Go toolchain + +**Depends On**: Task 1 + +**Acceptance Criteria**: +- Compiles with `go build ./...` +- `GetInstalledVersion()` returns a valid `Version` when pointed at a real binary +- Progress events sent to WebSocket room during download + +--- + +### Task 3: version_manager_test.go — Tests for core logic + +**Description**: Unit tests for version comparison, version range generation, and credential checking. + +**File**: `src/factorio/version_manager_test.go` (new) + +**Tests** (per spec §12.1): + +| Test | Input | Expected | +|---|---|---| +| `TestGetInstalledVersion` | Real factorio binary at configured path | Valid Version parsed from --version | +| `TestFetchAvailableVersions` | Mock HTTP server returning latest-releases | []AvailableVersion with stable + experimental | +| `TestDownloadAndInstall` | Mock HTTP serving valid tar.xz | Binary extracted at FactorioDir | +| `TestInstallRequiresCredentials` | No factorio.auth file | Error returned | +| `TestInstallWithBadVersion` | Version "0.0.0" | Invalid version error | +| `TestIsUpdateAvailable` | (2.0.28, 2.0.32) | true | +| `TestIsUpdateAvailable_Equal` | (2.0.32, 2.0.32) | false | +| `TestVersionRangeGeneration` | stable=2.0.32, experimental=2.0.32 | List covers 2.0.0..2.0.32, 2.0.32 is both | + +Use `testing.Short()` skip for tests needing external HTTP or real binary. + +**Delegation Recommendation**: +- Category: `quick` — straightforward unit test patterns matching existing style +- Skills: none + +**Depends On**: Task 2 + +**Acceptance Criteria**: +- `cd src && go test -short ./factorio/ -run TestVersion` passes + +--- + +### Task 4: version_handler.go — API HTTP handlers + +**Description**: Three handler functions following the existing pattern in `mod_portal_handler.go`: `defer WriteResponse` + `w.Header().Set("Content-Type", ...)` + explicit status codes. + +**File**: `src/api/version_handler.go` (new) + +**Handlers**: + +**`CurrentVersionHandler`**: +- Calls `factorio.GetInstalledVersion()` (or reads from singleton `factorio.GetFactorioServer().Version`) +- Returns `{"version": "2.0.28", "string": "2.0.28"}` +- Error 500 on failure + +**`AvailableVersionsHandler`**: +- Calls `factorio.FetchAvailableVersions()` +- Returns `{"stable": "2.0.32", "experimental": "2.0.32", "versions": [...]}` +- Error 502 on API fetch failure + +**`InstallVersionHandler`**: +- Reads `{"version": "2.0.32"}` from body via `ReadFromRequestBody` +- Validates with `Version.UnmarshalText()` +- Gets previous version before installing +- Calls `factorio.DownloadAndInstall()` +- Returns `{"success": true, "version": "2.0.32", "previousVersion": "2.0.28", "installedAt": "RFC3339"}` +- Error 400 bad version, 403 no credentials, 500 install failure + +**Delegation Recommendation**: +- Category: `deep` — request parsing + multiple error paths + WS integration +- Skills: none + +**Depends On**: Task 2 + +**Acceptance Criteria**: +- Compiles +- Each handler returns correct JSON shape per spec §3 + +--- + +### Task 5: routes.go — Register backend API routes + +**Description**: Add three new route entries to `apiRoutes` slice. + +**File**: `src/api/routes.go` + +**Changes to `apiRoutes`** — append three routes (before the closing `}`): + +```go +{ + "CurrentVersion", + "GET", + "/server/version/current", + CurrentVersionHandler, + false, +}, { + "AvailableVersions", + "GET", + "/server/version/available", + AvailableVersionsHandler, + false, +}, { + "InstallVersion", + "POST", + "/server/version/install", + InstallVersionHandler, + true, // requires server-off +}, +``` + +**Delegation Recommendation**: +- Category: `quick` — simple slice append following existing pattern +- Skills: none + +**Depends On**: Task 4 + +**Acceptance Criteria**: +- `go build ./...` compiles +- Routes listed in `NewRouter()` output when inspected + +--- + +### Task 6: routes.go — Frontend SPA route + +**Description**: Add frontend file-server route for `/server-version` alongside existing routes like `/saves`, `/mods`, `/logs`. + +**File**: `src/api/routes.go` + +**Change** — add after the `/logs` route block (around line 116): + +```go +subRouter.Path("/server-version"). + Methods("GET"). + Name("Server Version"). + Handler(http.StripPrefix("/server-version", http.FileServer(http.Dir("./app/")))) +``` + +**Delegation Recommendation**: +- Category: `quick` — single route addition following exact existing pattern +- Skills: none + +**Depends On**: None + +**Acceptance Criteria**: +- Route registered in router +- Navigable to `/server-version` in browser + +--- + +### Task 7: version_handler_test.go — Tests for API handlers + +**Description**: Integration tests for the three version endpoints using existing `CallRoute` pattern from `mods_handler_test.go`. + +**File**: `src/api/version_handler_test.go` (new) + +**Tests** (per spec §12.1): + +| Test | Method | Route | Expected | +|---|---|---|---| +| `TestCurrentVersion` | GET | `/api/server/version/current` | 200 + `{"version":"...", "string":"..."}` | +| `TestAvailableVersions` | GET | `/api/server/version/available` | 200 + version list (or skip in short mode) | +| `TestInstall_ServerRunning` | POST | `/api/server/version/install` | 423 Locked | +| `TestInstall_InvalidVersion` | POST | `/api/server/version/install` | 400 bad version | + +**Delegation Recommendation**: +- Category: `quick` — simple handler tests following existing patterns +- Skills: none + +**Depends On**: Task 4, Task 5 + +**Acceptance Criteria**: +- `cd src && go test -short ./api/ -run TestVersion` passes + +--- + +### Task 8: en/serverVersion.json — English i18n strings + +**File**: `ui/locales/en/serverVersion.json` (new) + +Content per spec §10.1 — all keys as documented. + +**Delegation Recommendation**: +- Category: `quick` — static JSON file +- Skills: none + +**Depends On**: None + +**Acceptance Criteria**: +- JSON is valid +- All keys from spec are present + +--- + +### Task 9: zh-CN/serverVersion.json — Chinese i18n strings + +**File**: `ui/locales/zh-CN/serverVersion.json` (new) + +Content per spec §10.1 — all keys translated. + +**Delegation Recommendation**: +- Category: `quick` — static JSON file +- Skills: none + +**Depends On**: None + +**Acceptance Criteria**: +- JSON is valid +- All keys match en version structure + +--- + +### Task 10: i18n.js — Register new serverVersion namespace + +**File**: `ui/i18n.js` + +**Changes**: +- Import `enServerVersion` and `zhServerVersion` +- Add `serverVersion: enServerVersion` to `resources.en` +- Add `serverVersion: zhServerVersion` to `resources['zh-CN']` + +**Delegation Recommendation**: +- Category: `quick` — 4-line addition following exact existing import/registration pattern +- Skills: none + +**Depends On**: Task 8, Task 9 + +**Acceptance Criteria**: +- i18n loads without errors +- `t('title', { ns: 'serverVersion' })` returns the english title string + +--- + +### Task 11: server.js + socket.js — API resource methods + WS subscription + +**Files**: `ui/api/resources/server.js`, `ui/api/socket.js` + +**server.js changes**: +- Add `currentVersion()` — `client.get('/api/server/version/current')` +- Add `availableVersions()` — `client.get('/api/server/version/available')` +- Add `installVersion(version)` — `client.post('/api/server/version/install', { version })` + +**socket.js changes**: +- In `registerEventEmitter`/`unregisterEventEmitter`, add handler functions: + ```js + function versionSubscribeEvent() { + socket.send(JSON.stringify({room_name: "", controls: {type: "subscribe", value: "server_version"}})); + } + function versionUnsubscribeEvent() { + socket.send(JSON.stringify({room_name: "", controls: {type: "unsubscribe", value: "server_version"}})); + } + ``` +- Register: `bus.on('version progress subscribe', versionSubscribeEvent);` +- Unregister: `bus.off('version progress subscribe', versionSubscribeEvent);` + unsubscribe + +**Delegation Recommendation**: +- Category: `quick` — straightforward method additions following patterns +- Skills: none + +**Depends On**: None + +**Acceptance Criteria**: +- `server.installVersion('2.0.32')` sends POST with correct body +- `socket.emit('version progress subscribe')` sends WS subscribe for `server_version` room + +--- + +### Task 12: ServerVersion.jsx — New view component + +**Description**: Full React component implementing the UI layout from spec §4. + +**File**: `ui/App/views/ServerVersion.jsx` (new) + +**States covered** (per spec §4.3): + +| State | UI | +|---|---| +| Loading | "Loading..." in each section | +| Loaded, up-to-date | Green checkmark + "Up to date" | +| Loaded, update available | "Update Available" badge + big CTA button | +| Installing | Progress bar (Tailwind) + disabled buttons | +| Error | Red error banner | +| No credentials | Link to Mod Portal settings page | + +**Key implementation**: +- `useTranslation('serverVersion')` +- `useEffect` fetch current + available versions on mount +- Subscribe to `server_version` WS room for progress events +- `handleInstall(version)` — calls `server.installVersion`, shows progress +- Version list with stable/experimental labels +- Confirmation via `window.confirm` (or existing `ConfirmDialog` component) +- Progress bar: `
` + +**Delegation Recommendation**: +- Category: `visual-engineering` — new React view with i18n and WS integration +- Skills: none specific + +**Depends On**: Task 10, Task 11 + +**Acceptance Criteria**: +- Component renders all states without errors +- Progress bar appears and updates during install +- Error display matches spec §7.3 + +--- + +### Task 13: App.jsx — Route registration + +**File**: `ui/App/App.jsx` + +**Changes**: +- Add `import ServerVersion from "./views/ServerVersion";` +- Add route: + ```jsx + } /> + ``` + +**Delegation Recommendation**: +- Category: `quick` — single import + single line route +- Skills: none + +**Depends On**: Task 12 + +**Acceptance Criteria**: +- `/server-version` renders ServerVersion component + +--- + +### Task 14: Layout.jsx — Sidebar link + layout i18n + +**Files**: `ui/App/components/Layout.jsx`, `ui/locales/en/layout.json`, `ui/locales/zh-CN/layout.json` + +**Layout.jsx changes**: +- Add link after ``: + ```jsx + {t('linkServerVersion', { ns: 'layout' })} + ``` +- Move `last={true}` from `linkLogs` to this new link + +**layout.json changes**: +- `en/layout.json`: add `"linkServerVersion": "Server Version"` +- `zh-CN/layout.json`: add `"linkServerVersion": "服务器版本"` + +**Delegation Recommendation**: +- Category: `quick` — 1 link + 2 JSON keys +- Skills: none + +**Depends On**: Task 13 + +**Acceptance Criteria**: +- Sidebar shows "Server Version" link in Server Management section +- Navigation to /server-version works from sidebar + +--- + +### Task 15: Build + Verify + +**Description**: Build both frontend and backend, verify compilation. + +**Steps**: +1. `npm install` +2. `npm run build` +3. `cd src && go build -o ../factorio-server-manager/factorio-server-manager .` +4. `cd src && go vet ./...` +5. Verify `app/bundle.js` and binary exist and are non-empty + +**Delegation Recommendation**: +- Category: `quick` — run build commands, check output +- Skills: none + +**Depends On**: All tasks + +**Acceptance Criteria**: +- Frontend builds with no errors +- Backend builds with no errors +- All `go vet` checks pass + +--- + +## Commit Strategy + +1. After writing this plan: + ``` + 文档: 添加服务器版本管理器实现计划 + ``` + +2. After full implementation (single atomic commit): + ``` + 功能: 实现服务器版本管理器 + ``` + + If intermediate commits are useful during development, squash to a single commit before final. + +--- + +## Success Criteria + +1. All tasks pass their acceptance criteria +2. `cd src && go build ./...` succeeds +3. `npm run build` succeeds +4. `cd src && go test -short ./...` passes +5. Three API endpoints return correct JSON per spec §3 +6. Frontend renders all states (loading, loaded, installing, error) +7. Progress events flow backend → WS room → frontend progress bar +8. Install blocked when server running (423) or no credentials (403) diff --git a/docs/superpowers/specs/2026-06-21-fsm-i18n-design.md b/docs/superpowers/specs/2026-06-21-fsm-i18n-design.md new file mode 100644 index 00000000..ad0a2e45 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-fsm-i18n-design.md @@ -0,0 +1,735 @@ +# FSM Frontend Internationalization (i18n) Design Spec + +**Date:** 2026-06-21 +**Project:** factorio-server-manager-joey +**Branch:** feat/fsm-i18n +**Status:** Draft / Approved for Implementation + +--- + +## 1. Overview and Goals + +### 1.1 Problem + +The Factorio Server Manager (FSM) frontend has zero internationalization support. All UI strings are hardcoded English embedded in JSX components. This blocks adoption by non-English-speaking server operators, particularly the Chinese community where Factorio has a large player base. + +### 1.2 Goals + +- Enable the UI to render in Simplified Chinese (zh-CN) as the first translated locale. +- Keep English as the authoritative fallback language (maintain existing strings as the source of truth). +- Introduce a translation architecture that makes adding future locales trivial (single new JSON folder). +- Minimize component refactoring: the `useTranslation()` Hook pattern mirrors existing React functional component style. +- Zero backend changes. Go error messages, log entries, and RCON output are not translated. +- Complete coverage of all ~200 UI strings across 9 view/page areas. + +### 1.3 Non-Goals (see also Section 10) + +- Translating Go backend strings, error messages, or Factorio server log output. +- Runtime locale switching without page reload (nice-to-have, not required for MVP). +- Right-to-left (RTL) language support. +- Pluralization rules or ICU MessageFormat. +- Integration with CI translation services (POEditor, Crowdin, etc.). + +--- + +## 2. Architecture + +### 2.1 Framework: react-i18next + i18next + +Chosen over alternatives (react-intl, LinguiJS) because: + +- `useTranslation()` Hook matches the existing functional component + Hooks pattern perfectly. +- No Redux or global state manager dependency (the app uses only `useState`). +- Backend-agnostic: translations are loaded as static JSON at build time, no XHR needed. +- `i18next-browser-languagedetector` provides zero-config language detection from `navigator.language`. + +### 2.2 Installation + +```bash +npm install i18next react-i18next i18next-browser-languagedetector +``` + +All three packages are runtime dependencies. No additional Webpack plugins or loaders are required (JSON imports are already supported by Webpack 5 with the default `json` module type; the existing config resolves `.json` extensions via `resolve.extensions`). + +### 2.3 Initialization + +A single initialization module is created at `ui/i18n.js`: + +```js +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import enCommon from './locales/en/common.json'; +import enLayout from './locales/en/layout.json'; +import enControls from './locales/en/controls.json'; +import enMods from './locales/en/mods.json'; +import enSaves from './locales/en/saves.json'; +import enServerSettings from './locales/en/serverSettings.json'; +import enLogs from './locales/en/logs.json'; +import enConsole from './locales/en/console.json'; +import enUserManagement from './locales/en/userManagement.json'; + +import zhCommon from './locales/zh-CN/common.json'; +import zhLayout from './locales/zh-CN/layout.json'; +import zhControls from './locales/zh-CN/controls.json'; +import zhMods from './locales/zh-CN/mods.json'; +import zhSaves from './locales/zh-CN/saves.json'; +import zhServerSettings from './locales/zh-CN/serverSettings.json'; +import zhLogs from './locales/zh-CN/logs.json'; +import zhConsole from './locales/zh-CN/console.json'; +import zhUserManagement from './locales/zh-CN/userManagement.json'; + +const resources = { + en: { + common: enCommon, + layout: enLayout, + controls: enControls, + mods: enMods, + saves: enSaves, + serverSettings: enServerSettings, + logs: enLogs, + console: enConsole, + userManagement: enUserManagement, + }, + 'zh-CN': { + common: zhCommon, + layout: zhLayout, + controls: zhControls, + mods: zhMods, + saves: zhSaves, + serverSettings: zhServerSettings, + logs: zhLogs, + console: zhConsole, + userManagement: zhUserManagement, + }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + defaultNS: 'common', + ns: [ + 'common', 'layout', 'controls', 'mods', 'saves', + 'serverSettings', 'logs', 'console', 'userManagement', + ], + interpolation: { + escapeValue: false, // React already escapes output + }, + detection: { + // Only check localStorage and navigator.language + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'fsm_lang', + }, + }); + +export default i18n; +``` + +### 2.4 Provider Wrapping + +In `ui/index.js`, import the i18n instance and wrap `` with ``: + +```jsx +import './i18n'; // initialize i18n before rendering +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App/App.jsx'; + +window.flash = (message, color = 'gray-light') => + Bus.emit('flash', ({ message, color })); + +const root = ReactDOM.createRoot(document.getElementById('app')); +root.render( + Loading...
}> + + +); +``` + +`react-i18next` uses `Suspense` internally to delay rendering until translations are loaded. The `fallback` shows a brief loading indicator. For this app with static JSON imports, the Suspense resolves synchronously, so the fallback is a safety measure only. + +### 2.5 Translation File Structure + +``` +ui/locales/ + en/ + common.json + layout.json + controls.json + mods.json + saves.json + serverSettings.json + logs.json + console.json + userManagement.json + zh-CN/ + common.json + layout.json + controls.json + mods.json + saves.json + serverSettings.json + logs.json + console.json + userManagement.json +``` + +Every namespace folder mirrors `en/` exactly. Missing keys in `zh-CN/*.json` automatically fall back to `en/*.json` at runtime (no crash, no missing string). + +--- + +## 3. Component-Level i18n Integration Pattern + +### 3.1 Basic Pattern + +**Before** (hardcoded English): + +```jsx +import React from 'react'; +import Button from '../components/Button'; + +const Controls = ({ serverStatus }) => { + return ( + + ); +}; +``` + +**After** (translated): + +```jsx +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Button from '../components/Button'; + +const Controls = ({ serverStatus }) => { + const { t } = useTranslation('controls'); + + return ( + + ); +}; +``` + +### 3.2 Component with Multiple Namespaces + +When a view imports strings from `common` and its own namespace: + +```jsx +const { t } = useTranslation(['mods', 'common']); +// Usage: +t('installMod') // looks in 'mods' first +t('save') // looks in 'common' (fallback namespace) +t('common:save') // explicit namespace prefix +``` + +Explicit prefix (`'common:save'`) is preferred for clarity when mixing namespaces, except in trivial cases. + +### 3.3 Interpolation (Dynamic Values) + +For strings that embed variables (save names, usernames, version numbers): + +**Translation JSON** (`en/common.json`): +```json +{ + "saveLastModified": "Last Modified: {{date}}", + "deleteConfirm": "Are you sure you want to delete \"{{name}}\"?", + "versionInfo": "Version {{version}}" +} +``` + +**Component usage:** +```jsx +{t('saveLastModified', { date: save.last_mod })} +{t('deleteConfirm', { name: save.name })} +{t('versionInfo', { version: factorioVersion })} +``` + +### 3.4 No Changes to Pure Presentational Components + +Components like `Button`, `Panel`, `Input`, `Label`, `Modal` do NOT import `useTranslation`. They accept `children` or `text` props. The translation call happens at the call site. + +**Exception:** `ConfirmDialog` receives translated `title`, `content` props from its parent. Its own button labels ("Cancel", "Confirm") are translated inside the component via `useTranslation('common')`. + +### 3.5 Changes Outside JSX + +For the `window.flash()` calls in JavaScript logic (e.g., error toasts): + +```jsx +import { useTranslation } from 'react-i18next'; +const { t } = useTranslation('common'); + +// Before: +window.flash("Login failed. Username or Password wrong.", "red"); + +// After: +window.flash(t('loginFailed'), "red"); +``` + +For non-component files (api resources, utility modules) that display user-facing messages, they must either accept a `t` function as a parameter or the calling component must wrap the message. + +--- + +## 4. Translation Key Naming Convention + +### 4.1 Rules + +- **Flat camelCase.** No dots, no nesting. Each JSON file is a single-level object. +- **VerbPrefix for actions.** `startServer`, `stopServer`, `deleteSave`, `uploadMod`. +- **NounOnly for labels.** `serverStatus`, `factorioVersion`, `lastModified`, `actions`. +- **Status constants in SCREAMING_SNAKE.** `RUNNING`, `STOPPED`, `UNKNOWN`. +- **Error messages:** `errorRequired`, `errorInvalidPort`, `loginFailed`. +- **Confirm dialogs:** `confirmDeleteMod`, `confirmStopServer`. +- **Placeholder text:** `placeholderUsername`, `placeholderPassword`. + +### 4.2 Examples per Category + +``` +# Controls +"startServer" "Start Server" +"saveStopServer" "Save & Stop Server" +"killServer" "Kill Server" +"serverStatus" "Server Status" +"status" "Status" +"ip" "IP" +"port" "Port" +"factorioVersion" "Factorio Version" +"save" "Save" +"RUNNING" "Running" +"STOPPED" "Stopped" +"UNKNOWN" "Unknown" + +# Common +"save" "Save" +"cancel" "Cancel" +"confirm" "Confirm" +"delete" "Delete" +"loading" "Loading..." +"errorOccurred" "An error occurred" +"loginFailed" "Login failed. Username or Password wrong." +"name" "Name" +"actions" "Actions" +``` + +### 4.3 What NOT to Do + +``` +# BAD - deep nesting +"server.controls.start" "Start Server" + +# BAD - inconsistent casing +"start_server" "Start Server" +"stopServer" "Stop Server" + +# BAD - HTML in values +"welcomeMessage" "Welcome to FSM" +``` + +Values are plain strings. No HTML markup inside JSON values. Components use React elements if formatting is needed. + +--- + +## 5. Language Detection and Switching UX + +### 5.1 Auto-Detection + +On first visit, `i18next-browser-languagedetector` checks `navigator.language`: + +| Browser Language | Selected Locale | +|---|---| +| `zh-CN`, `zh-TW`, `zh-HK`, `zh` | `zh-CN` | +| Any other value | `en` | + +The check uses simple prefix matching: if `navigator.language` starts with `zh`, load `zh-CN`. Otherwise, load `en`. + +### 5.2 Persistence + +The selected language is stored in `localStorage` key `fsm_lang`. On subsequent visits, the stored preference takes priority over `navigator.language`. + +Clearing `localStorage` or using a private/incognito window triggers auto-detection again. + +### 5.3 Language Switcher UI + +A language toggle is added to the Layout sidebar, inside the "FSM Administration" section, below the Help link: + +```jsx +// In Layout.jsx, after the Help link: +Users +Help +{/* new */} +
+ +
+``` + +The ` setValue(optionElement.target.value)} + onChange={e => { + setValue(e.target.value); + if (register?.onChange) register.onChange(e); + }} > {options.map(option => )} diff --git a/ui/App/components/Tabs/TabControl.jsx b/ui/App/components/Tabs/TabControl.jsx index a2a7d94e..b4cabf66 100644 --- a/ui/App/components/Tabs/TabControl.jsx +++ b/ui/App/components/Tabs/TabControl.jsx @@ -1,9 +1,23 @@ -import React, {useState} from "react"; +import React, {useState, useEffect} from "react"; import TabTitle from "./TabTitle"; -const TabControl = ({children}) => { +const TabControl = ({children, activeIndex}) => { const [selectedTab, setSelectedTab] = useState(0) + useEffect(() => { + if (activeIndex !== undefined && activeIndex !== selectedTab) { + setSelectedTab(activeIndex); + } + }, [activeIndex]); + + const handleSelect = (index) => { + setSelectedTab(index); + const child = children[index]; + if (child && child.props.onActivate) { + child.props.onActivate(); + } + } + return (
@@ -13,7 +27,7 @@ const TabControl = ({children}) => { title={item.props.title} index={index} isActive={index === selectedTab} - setSelectedTab={setSelectedTab} + setSelectedTab={handleSelect} /> ))}
diff --git a/ui/App/eventLog.js b/ui/App/eventLog.js new file mode 100644 index 00000000..0b060d22 --- /dev/null +++ b/ui/App/eventLog.js @@ -0,0 +1,46 @@ +const MAX_EVENTS = 500; + +let listeners = []; +let events = []; + +export const eventLog = { + add(event) { + const entry = { + id: Date.now() + '_' + Math.random().toString(36).slice(2, 6), + time: new Date().toISOString(), + type: event.type || 'info', + message: event.message || '', + source: event.source || 'app', + }; + events.push(entry); + if (events.length > MAX_EVENTS) events = events.slice(-MAX_EVENTS); + listeners.forEach(fn => fn(entry)); + return entry; + }, + + getAll() { + return [...events]; + }, + + clear() { + events = []; + listeners.forEach(fn => fn({type: 'system', message: 'log cleared', source: 'system'})); + }, + + subscribe(fn) { + listeners.push(fn); + return () => { listeners = listeners.filter(f => f !== fn); }; + }, + + error(msg, source) { + return this.add({type: 'error', message: String(msg), source: source || 'app'}); + }, + + warn(msg, source) { + return this.add({type: 'warn', message: String(msg), source: source || 'app'}); + }, + + info(msg, source) { + return this.add({type: 'info', message: String(msg), source: source || 'app'}); + }, +}; diff --git a/ui/App/i18n.js b/ui/App/i18n.js new file mode 100644 index 00000000..6de916ad --- /dev/null +++ b/ui/App/i18n.js @@ -0,0 +1,69 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import Backend from 'i18next-http-backend' +import LanguageDetector from "i18next-browser-languagedetector"; +import enTranslations from "./locales/en.json"; +import enCommonTranslations from "./locales/en-common.json"; +import ruTranslations from "./locales/ru.json"; +import zhTranslations from "./locales/zh.json"; +import zhCommonTranslations from "./locales/zh-common.json"; +import zhControlsTranslations from "./locales/zh-controls.json"; +import zhConsoleTranslations from "./locales/zh-console.json"; +import zhLayoutTranslations from "./locales/zh-layout.json"; +import zhLogsTranslations from "./locales/zh-logs.json"; +import zhModsTranslations from "./locales/zh-mods.json"; +import zhSavesTranslations from "./locales/zh-saves.json"; +import zhServersettingsTranslations from "./locales/zh-serverSettings.json"; +import zhServerversionTranslations from "./locales/zh-serverVersion.json"; +import zhUsermanagementTranslations from "./locales/zh-userManagement.json"; + +const resources = +{ + en: + { + translation: enTranslations, + common: enCommonTranslations, + }, + ru: + { + translation: ruTranslations, + common: enCommonTranslations, + }, + zh: + { + translation: zhTranslations, + common: zhCommonTranslations, + controls: zhControlsTranslations, + console: zhConsoleTranslations, + layout: zhLayoutTranslations, + logs: zhLogsTranslations, + mods: zhModsTranslations, + saves: zhSavesTranslations, + serverSettings: zhServersettingsTranslations, + serverVersion: zhServerversionTranslations, + userManagement: zhUsermanagementTranslations, + } +}; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + ns: ["translation", "common", "controls", "console", "layout", "logs", "mods", "saves", "serverSettings", "serverVersion", "userManagement"], + defaultNS: 'translation', + resources, + fallbackLng: "en", // Default Language + // Detecting and caching of language cookies + detection: + { + order: ["localStorage", "cookie", "navigator"], + cache: ["localStorage", "cookie"] + }, + interpolation: + { + escapeValue: false // react already safes from xss + } + }); + +export default i18n; \ No newline at end of file diff --git a/ui/App/locales/en-common.json b/ui/App/locales/en-common.json new file mode 100644 index 00000000..23b2316f --- /dev/null +++ b/ui/App/locales/en-common.json @@ -0,0 +1,17 @@ +{ + "username": "Username", + "password": "Password", + "upload": "Upload", + "name": "Name", + "actions": "Actions", + "role": "Role", + "email": "Email", + "save": "Save", + "logout": "Logout", + "cancel": "Cancel", + "create": "Create", + "confirm": "Confirm", + "change": "Change", + "install": "Install", + "load": "Load" +} \ No newline at end of file diff --git a/ui/App/locales/en.json b/ui/App/locales/en.json new file mode 100644 index 00000000..d05cccf2 --- /dev/null +++ b/ui/App/locales/en.json @@ -0,0 +1,211 @@ +{ + "save": "Save", + "upload": "Upload", + "saved": "Settings saved.", + "create": "Create", + "cancel": "Cancel", + "confirm": "Confirm", + "name": "Name", + "username": "Username", + "password": "Password", + "logout": "Logout", + "install": "Install", + "actions": "Actions", + "role": "Role", + "email": "Email", + "change": "Change", + "load": "Load", + "server_status": "Server Status", + "server_management": "Server Management", + "FSM_administration": "FSM Administration", + "main_title": "Factorio Server Manager", + "server_settings": { + "title": "Server settings", + "admins": "Admins", + "name": "Name", + "description": "Description", + "tags": "Tags", + "_comment_max_players": "Maximum number of players allowed, admins can join even a full server. 0 means unlimited.", + "max_players": "Max players", + "_comment_visibility": "Public: Game will be published on the official Factorio matching server. Lan: Game will be broadcast on LAN", + "visibility______": { + "public_": "Public", + "lan_": "Lan" + }, + "visibility": "Visibility", + "_comment_credentials": "Your factorio.com login credentials. Required for games with visibility public", + "username": "Username", + "password": "Password", + "_comment_token": "Authentication token. May be used instead of 'password' above.", + "token": "Token", + "game_password": "Game password", + "_comment_require_user_verification": "When set to true, the server will only allow clients that have a valid Factorio.com account", + "require_user_verification": "Require user verification", + "_comment_max_upload_in_kilobytes_per_second": "Optional, default value is 0. 0 means unlimited.", + "max_upload_in_kilobytes_per_second": "Max upload in kilobytes per second", + "_comment_max_upload_slots": "Optional, default value is 5. 0 means unlimited.", + "max_upload_slots": "Max upload slots", + "_comment_minimum_latency_in_ticks": "Optional one tick is 16ms in default speed, default value is 0. 0 means no minimum.", + "minimum_latency_in_ticks": "Minimum latency in ticks", + "_comment_max_heartbeats_per_second": "Network tick rate. Maximum rate game updates packets are sent at before bundling them together. Minimum value is 6, maximum value is 240.", + "max_heartbeats_per_second": "Max heartbeats per second", + "_comment_ignore_player_limit_for_returning_players": "Players that played on this map already can join even when the max player limit was reached.", + "ignore_player_limit_for_returning_players": "Ignore player limit for returning players", + "_comment_allow_commands": "Possible values are: true, false and admins-only", + "allow_commands": "Allow commands", + "_comment_autosave_interval": "Autosave interval in minutes", + "autosave_interval": "Autosave interval", + "_comment_autosave_slots": "Server autosave slots, it is cycled through when the server autosaves.", + "autosave_slots": "Autosave slots", + "_comment_afk_autokick_interval": "How many minutes until someone is kicked when doing nothing, 0 for never.", + "afk_autokick_interval": "Afk autokick interval", + "_comment_auto_pause": "Whether should the server be paused when no players are present.", + "auto_pause": "Auto pause", + "_comment_auto_pause_when_players_connect": "Whether should the server be paused when someone is connecting to the server.", + "auto_pause_when_players_connect": "Auto pause when players connect", + "only_admins_can_pause_the_game": "Only admins can pause the game", + "_comment_autosave_only_on_server": "Whether autosaves should be saved only on server or also on all connected clients. Default is true.", + "autosave_only_on_server": "Autosave only on server", + "_comment_non_blocking_saving": "Highly experimental feature, enable only at your own risk of losing your saves. On UNIX systems, server will fork itself to create an autosave. Autosaving on connected Windows clients will be disabled regardless of autosave_only_on_server option.", + "non_blocking_saving": "Non blocking saving", + "_comment_segment_sizes": "Long network messages are split into segments that are sent over multiple ticks. Their size depends on the number of peers currently connected. Increasing the segment size will increase upload bandwidth requirement for the server and download bandwidth requirement for clients. This setting only affects server outbound messages. Changing these settings can have a negative impact on connection stability for some clients.", + "minimum_segment_size": "Minimum segment size", + "minimum_segment_size_peer_count": "Minimum segment size peer count", + "maximum_segment_size": "Maximum segment size", + "maximum_segment_size_peer_count": "Maximum segment size peer count" + }, + "saves": { + "create_save": "Create Save", + "upload_save": "Upload Save", + "title": "Saves", + "last_modified": "Last Modified At", + "size": "Size", + "actions": "Actions", + "create_new_save_only_when_server_not_running": "Create a new Save is only possible if the Factorio server is not running.", + "save_form": { + "file_name": "Savefile Name", + "save_file_error_message": "Savefile Name is required", + "create_save": "Create Save" + }, + "upload_form": { + "select_file": "Select File ...", + "file_name": "Savefile", + "save_file_error_message": "Savefile is required", + "upload": "Upload" + } + }, + "controls": { + "title": "Server Status", + "status": "Status", + "unknown": "Unknown", + "running": "Running", + "stopped": "Stopped", + "port": "Port", + "IP_error_message": "IP is required and must be valid.", + "port_error_message": "Port is required within range 1-65535", + "save_error_message": "Save is required and must be valid.", + "f_version": "Factorio version", + "save": "Save", + "save&stop": "Save & Stop Server", + "kill_server": "Kill Server", + "start_server": "Start Server" + }, + "mods": { + "change_mods_while_running_error_message": "Changing mods is disabled while the server is running!", + "install_mod": "Install Mod", + "upload_mod": "Upload Mod", + "load_mods": "Load Mods from Save", + "title": "Mods", + "delete_all": "Delete all Mods", + "update_all": "Update all Mods", + "download_all": "Download all Mods", + "mod_packs": "Mod packs", + "select_file": "Select File ...", + "mods_loaded_from_save": "Mods are loaded from save file @@@.", + "load_mods_from_save": "Load Mods from Save", + "load_confirm_dialog": "Loading the Mods from Save @@@ will remove all currently installed Mods.", + "add_modpack_with_current_mods": "Add ModPack with current installed Mods", + "add_mod": { + "loading_mod_list": "Loading List of Mods from", + "version": "Version", + "compatibility": "Compatibility" + }, + "mod_list": { + "enabled": "Enabled", + "compatibility": "Compatibility", + "mod_version": "Mod Version", + "factorio_version": "Factorio Version" + }, + "mod_pack": { + "load_modpack": "Load ModPack", + "load_confirm_dialog": "Loading the ModPack @@@ will remove all installed Mods." + }, + "sync_from_save": "Sync Mods from Save", + "sync_started": "Sync started...", + "sync_downloading": "Downloading", + "sync_done": "Sync complete", + "sync_error": "Sync error", + "sync_warning_vanilla": "Save was created without gameplay mods. Mods may have been added later \u2014 sync may be incomplete.", + "status_downloaded": "Downloaded", + "status_already_installed": "Already installed", + "status_builtin": "Built-in / DLC", + "status_not_found": "Not found on portal", + "status_downloading": "Downloading..." + }, + "login": { + "login_failed_message": "Login failed. Username or Password wrong.", + "title": "Login", + "login": "Login", + "username_error_message": "Username is required", + "password_error_message": "Password is required", + "sign_in": "Sign In", + "factorio_login_error_message": "Given username or email and password do not match any account." + }, + "console": { + "title": "Console", + "error": "The console is not available, because Factorio is not running." + }, + "logs": { + "title": "Logs" + }, + "help": { + "title": "Help", + "fsm": "Factorio Server Manager", + "fsm_content": "The Factorio Server Manager (FSM) is an open source project and is not affiliated to the game Factorio or Wube Software.", + "bugs_help": "Bugs and Help", + "bugs_help_content": "Please use the <0>GitHub repository to report bugs or seek for help.", + "helpful_resources": "Helpful Resources", + "factorio_link_text": "Official Factorio Wiki about Multiplayer" + }, + "users": { + "title": "Users", + "user_list": "List of Users", + "change_password": { + "title": "Change Password", + "update_successful": "Password changed", + "old_password": "Old Password", + "old_password_error": "Old Password is required", + "new_password": "New Password", + "new_password_error": "New Password is required", + "new_password_confirmation": "New Password Confirmation", + "new_password_confirmation_error": "New Password Confirmation is required" + }, + "create_user": { + "title": "Create User", + "username_error": "Username is required", + "role_error": "Role is required", + "email_error": "Email is required", + "password_error": "Password is required", + "password_confirmation": "Password Confirmation", + "password_confirmation_error": "Password Confirmation is required and must match the Password" + } + }, + "game_settings": { + "title": "Game Settings" + }, + "lang": "Language", + "refresh": "Refresh Mods", + "serverVersion": { + "title": "Server Version" + } +} \ No newline at end of file diff --git a/ui/App/locales/ru.json b/ui/App/locales/ru.json new file mode 100644 index 00000000..c25555a7 --- /dev/null +++ b/ui/App/locales/ru.json @@ -0,0 +1,206 @@ +{ + "save": "Сохранить", + "upload": "Загрузить", + "saved": "Настройки сохранены.", + "create": "Создать", + "cancel": "Отмена", + "confirm": "Подтвердить", + "name": "Имя", + "username": "Имя пользователя", + "password": "Пароль", + "logout": "Выйти", + "install": "Установить", + "actions": "Действия", + "role": "Роль", + "email": "Email", + "change": "Изменить", + "load": "Загрузить", + "server_status": "Состояние сервера", + "server_management": "Управление сервером", + "FSM_administration": "Администрирование FSM", + "main_title": "Factorio Server Manager", + "server_settings": { + "title": "Настройки сервера", + "admins": "Админы", + "name": "Название", + "description": "Описание", + "tags": "Теги", + "_comment_max_players": "Максимальное количество игроков. Администраторы могут присоединиться даже к полному серверу. 0 означает без ограничений.", + "max_players": "Максимум игроков", + "_comment_visibility": "Public: Игра будет опубликована на официальном сервере подбора Factorio. Lan: Игра будет транслироваться в локальной сети", + "visibility_____": { + "public_": "Публичный", + "lan_": "Локальная сеть" + }, + "visibility": "Видимость", + "_comment_credentials": "Ваши учётные данные для входа на factorio.com. Требуется для игр с публичной видимостью", + "username": "Имя пользователя", + "password": "Пароль", + "_comment_token": "Токен аутентификации. Может использоваться вместо 'пароля' выше.", + "token": "Токен", + "game_password": "Пароль игры", + "_comment_require_user_verification": "Если установлено значение true, сервер будет разрешать подключение только клиентам с действующей учётной записью Factorio.com", + "require_user_verification": "Требуется проверка пользователя", + "_comment_max_upload_in_kilobytes_per_second": "Необязательно, значение по умолчанию — 0. 0 означает без ограничений.", + "max_upload_in_kilobytes_per_second": "Максимальная загрузка в килобайтах в секунду", + "_comment_max_upload_slots": "Необязательно, значение по умолчанию — 5. 0 означает без ограничений.", + "max_upload_slots": "Максимальное количество слотов загрузки", + "_comment_minimum_latency_in_ticks": "Необязательно, один тик — это 16 мс при стандартной скорости, значение по умолчанию — 0. 0 означает отсутствие минимума.", + "minimum_latency_in_ticks": "Минимальная задержка в тиках", + "_comment_max_heartbeats_per_second": "Сетевая частота тиков. Максимальная частота, с которой пакеты обновлений игры отправляются перед их объединением. Минимальное значение — 6, максимальное — 240.", + "max_heartbeats_per_second": "Максимум сердечных сокращений в секунду", + "_comment_ignore_player_limit_for_returning_players": "Игроки, которые уже играли на этой карте, могут присоединиться даже при достижении лимита игроков.", + "ignore_player_limit_for_returning_players": "Игнорировать лимит игроков для возвращающихся игроков", + "_comment_allow_commands": "Возможные значения: true, false и только-админы", + "allow_commands": "Разрешить команды", + "_comment_autosave_interval": "Интервал автосохранения в минутах", + "autosave_interval": "Интервал автосохранения", + "_comment_autosave_slots": "Слоты автосохранения сервера, циклически используемые при автосохранении.", + "autosave_slots": "Слоты автосохранения", + "_comment_afk_autokick_interval": "Сколько минут до кика игрока за бездействие, 0 — никогда.", + "afk_autokick_interval": "Интервал автокика за AFK", + "_comment_auto_pause": "Следует ли ставить игру на паузу, когда нет игроков.", + "auto_pause": "Автопауза", + "_comment_auto_pause_when_players_connect": "Следует ли ставить игру на паузу, когда игрок подключается к серверу.", + "auto_pause_when_players_connect": "Автопауза при подключении игроков", + "only_admins_can_pause_the_game": "Только админы могут ставить игру на паузу", + "_comment_autosave_only_on_server": "Следует ли сохранять автосохранения только на сервере или также на всех подключённых клиентах. По умолчанию — true.", + "autosave_only_on_server": "Автосохранение только на сервере", + "_comment_non_blocking_saving": "Экспериментальная функция, включайте на свой страх и риск потери сохранений. На UNIX-системах сервер будет форкаться для создания автосохранения. Автосохранение на подключённых Windows-клиентах будет отключено независимо от опции autosave_only_on_server.", + "non_blocking_saving": "Неблокирующее сохранение", + "_comment_segment_sizes": "Длинные сетевые сообщения разбиваются на сегменты, которые отправляются за несколько тиков. Их размер зависит от количества подключённых пиров. Увеличение размера сегмента увеличит требования к пропускной способности загрузки для сервера и скачивания для клиентов. Эта настройка влияет только на исходящие сообщения сервера. Изменение этих настроек может негативно сказаться на стабильности соединения для некоторых клиентов.", + "minimum_segment_size": "Минимальный размер сегмента", + "minimum_segment_size_peer_count": "Количество пиров для минимального размера сегмента", + "maximum_segment_size": "Максимальный размер сегмента", + "maximum_segment_size_peer_count": "Количество пиров для максимального размера сегмента" + }, + "saves": { + "create_save": "Создать сохранение", + "upload_save": "Загрузить сохранение", + "title": "Сохранения", + "last_modified": "Последние изменение", + "size": "Вес", + "actions": "Действия", + "create_new_save_only_when_server_not_running": "Создать новый сохранённый файл возможно только если сервер Factorio не запущен.", + "save_form": { + "file_name": "Имя файла", + "save_file_error": "Имя файла обязательное поле", + "create_save": "Создать сохранение" + }, + "upload_form": { + "select_file": "Выберите сохранение ...", + "file_name": "Файл сохранения", + "save_file_error": "Файл сохранения обязателен", + "upload": "Загрузить" + } + }, + "controls": { + "title": "Статус сервера", + "status": "Статус", + "running": "Запущен", + "stopped": "Остановлен", + "port": "Порт", + "IP_error_message": "Требуется IP-адрес, который должен быть действительным.", + "port_error_message": "Требуется порт в диапазоне 1-65535", + "save_error_message": "Сохранение обязательно и должно быть действительным.", + "f_version": "Версия Factorio", + "save": "Сохранить", + "save&stop": "Сохранить и остановить сервер", + "kill_server": "Убить сервер", + "start_server": "Запустить сервер" + }, + "mods": { + "change_mods_while_running_error_message": "Изменение модов отключено, пока сервер работает!", + "install_mod": "Установить мод", + "upload_mod": "Загрузить мод", + "load_mods": "Загрузить моды из сохранения", + "title": "Моды", + "delete_all": "Удалить все моды", + "update_all": "Обновить все моды", + "download_all": "Скачать все моды", + "mod_packs": "Паки модов", + "select_file": "Выбрать файл...", + "mods_loaded_from_save": "Моды загружены из файла сохранения @@@.", + "load_mods_from_save": "Загрузить моды из сохранения", + "load_confirm_dialog": "Загрузка модов из сохранения @@@ удалит все текущие установленные моды.", + "add_modpack_with_current_mods": "Добавить пак модов с текущими установленными модами", + "add_mod": { + "loading_mod_list": "Загрузка списка модов из", + "version": "Версия", + "compatibility": "Совместимость" + }, + "mod_list": { + "enabled": "Включён", + "compatibility": "Совместимость", + "mod_version": "Версия мода", + "factorio_version": "Версия Factorio" + }, + "mod_pack": { + "load_modpack": "Загрузить пак модов", + "load_confirm_dialog": "Загрузка пака модов @@@ удалит все установленные моды." + }, + "sync_from_save": "Синхронизировать моды из сейва", + "sync_started": "Синхронизация запущена...", + "sync_downloading": "Скачивание", + "sync_done": "Синхронизация завершена", + "sync_error": "Ошибка синхронизации", + "sync_warning_vanilla": "Сейв создан без геймплейных модов. Моды могли быть добавлены позже — синхронизация может быть неполной.", + "status_downloaded": "Скачан", + "status_already_installed": "Уже установлен", + "status_builtin": "Встроенный / DLC", + "status_not_found": "Не найден на портале", + "status_downloading": "Скачивается..." + }, + "login": { + "login_failed_message": "Ошибка входа. Неверное имя пользователя или пароль.", + "title": "Вход", + "login": "Вход", + "username_error_message": "Требуется имя пользователя", + "password_error_message": "Требуется пароль", + "sing_in": "Войти", + "factorio_login_error_message": "Указанные имя пользователя, email или пароль не соответствуют ни одному аккаунту." + }, + "console": { + "title": "Консоль", + "error": "Консоль недоступна, так как Factorio не запущен." + }, + "logs": { + "title": "Логи" + }, + "help": { + "title": "Помощь", + "fsm": "Менеджер серверов Factorio", + "fsm_content": "Factorio Server Manager (FSM) — проект с открытым исходным кодом и не связан с игрой Factorio или компанией Wube Software.", + "bugs_help": "Сообщения об ошибках и помощь", + "bugs_help_content": "Пожалуйста, используйте <0>репозиторий GitHub для сообщения об ошибках или запроса помощи.", + "helpful_resources": "Полезные ресурсы", + "factorio_link_text": "Официальная вики Factorio о многопользовательской игре" + }, + "users": { + "title": "Пользователи", + "user_list": "Список пользователей", + "change_password": { + "title": "Изменить пароль", + "update_successful": "Пароль изменён", + "old_password": "Старый пароль", + "old_password_error": "Требуется старый пароль", + "new_password": "Новый пароль", + "new_password_error": "Требуется новый пароль", + "new_password_confirmation": "Подтверждение нового пароля", + "new_password_confirmation_error": "Требуется подтверждение нового пароля" + }, + "create_user": { + "title": "Создать пользователя", + "username_error": "Требуется имя пользователя", + "role_error": "Требуется роль", + "email_error": "Требуется email", + "password_error": "Требуется пароль", + "password_confirmation": "Подтверждение пароля", + "password_confirmation_error": "Требуется подтверждение пароля, и оно должно совпадать с паролем" + } + }, + "game_settings": { + "title": "Настройки игры" + }, + "lang": "Язык" +} \ No newline at end of file diff --git a/ui/App/locales/zh-common.json b/ui/App/locales/zh-common.json new file mode 100644 index 00000000..11cf4a87 --- /dev/null +++ b/ui/App/locales/zh-common.json @@ -0,0 +1,42 @@ +{ + "signIn": "登录", + "username": "用户名", + "password": "密码", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码", + "loginFailed": "登录失败,用户名或密码错误", + "upload": "上传", + "name": "名称", + "lastModifiedAt": "最后修改", + "size": "大小", + "actions": "操作", + "role": "角色", + "email": "邮箱", + "unknown": "未知", + "save": "保存", + "logout": "退出登录", + "cancel": "取消", + "create": "创建", + "confirm": "确认", + "change": "修改", + "delete": "删除", + "search": "搜索", + "install": "安装", + "load": "加载", + "update": "更新", + "download": "下载", + "remove": "删除", + "start": "启动", + "stop": "停止", + "kill": "强制终止", + "close": "关闭", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "submit": "提交", + "reset": "重置", + "enable": "启用", + "disable": "禁用", + "enabled": "已启用", + "disabled": "已禁用" +} \ No newline at end of file diff --git a/ui/App/locales/zh-console.json b/ui/App/locales/zh-console.json new file mode 100644 index 00000000..b3fea0a3 --- /dev/null +++ b/ui/App/locales/zh-console.json @@ -0,0 +1,19 @@ +{ + "title": "终端", + "console": "控制台", + "send": "发送", + "command": "指令", + "consoleNotAvailable": "控制台不可用(服务器未运行)", + "placeholder": "输入 Factorio 指令...", + "fontSize": "字体", + "wrap": "换行", + "small": "小", + "medium": "中", + "large": "大", + "on": "开", + "off": "关", + "width": "宽度", + "compact": "窄", + "normal": "中", + "full": "宽" +} \ No newline at end of file diff --git a/ui/App/locales/zh-controls.json b/ui/App/locales/zh-controls.json new file mode 100644 index 00000000..5ba05c88 --- /dev/null +++ b/ui/App/locales/zh-controls.json @@ -0,0 +1,30 @@ +{ + "title": "控制面板", + "serverStatus": "服务器状态", + "status": "状态", + "RUNNING": "运行中", + "STOPPED": "已停止", + "running": "运行中", + "stopped": "已停止", + "unknown": "未知", + "factorioVersion": "游戏版本", + "ip": "IP 地址", + "port": "端口", + "save": "存档", + "saveFile": "存档文件", + "saveRequired": "请选择存档", + "ipRequired": "请输入 IP 地址", + "portRequired": "请输入端口", + "killServer": "强制终止", + "startServer": "启动服务器", + "stopServer": "停止服务器", + "saveStopServer": "保存并停止", + "saveSettings": "保存设置", + "settingsSaved": "设置已保存", + "players": "在线玩家", + "uptime": "运行时长", + "compatible": "兼容", + "incompatible": "不兼容", + "chat": "聊天", + "noChatYet": "暂无聊天消息" +} \ No newline at end of file diff --git a/ui/App/locales/zh-layout.json b/ui/App/locales/zh-layout.json new file mode 100644 index 00000000..a3c5ca51 --- /dev/null +++ b/ui/App/locales/zh-layout.json @@ -0,0 +1,14 @@ +{ + "main_title": "异星工厂服务器管理器", + "server_status": "服务器状态", + "server_management": "服务器管理", + "FSM_administration": "FSM 管理", + "lang": "语言", + "logout": "退出登录", + "UNKNOWN": "未知", + "refresh": "刷新模组列表", + "appTitle": "Factorio Server Manager", + "helpBugsAndHelp": "反馈 BUG & 获取帮助", + "helpResources": "相关资源", + "helpTitle": "帮助" +} \ No newline at end of file diff --git a/ui/App/locales/zh-logs.json b/ui/App/locales/zh-logs.json new file mode 100644 index 00000000..812ccc1a --- /dev/null +++ b/ui/App/locales/zh-logs.json @@ -0,0 +1,6 @@ +{ + "title": "日志", + "logs": "日志", + "refresh": "刷新", + "autoScroll": "自动滚动" +} \ No newline at end of file diff --git a/ui/App/locales/zh-mods.json b/ui/App/locales/zh-mods.json new file mode 100644 index 00000000..de29face --- /dev/null +++ b/ui/App/locales/zh-mods.json @@ -0,0 +1,81 @@ +{ + "title": "模组管理", + "mods": "模组", + "installed": "已安装版本", + "portal": "模组门户", + "add": "添加模组", + "load": "加载", + "delete_all": "删除全部", + "update_all": "更新全部", + "install": "安装", + "upload": "上传", + "save": "存档", + "cancel": "取消", + "create": "创建", + "name": "名称", + "actions": "操作", + "search": "搜索...", + "logout": "退出登录", + "updateAllMods": "更新全部模组", + "downloadAllMods": "下载全部模组", + "deleteAllMods": "删除全部模组", + "modPacks": "模组包", + "createModPack": "创建模组包", + "modPortal": "模组门户", + "installMod": "安装模组", + "uploadMod": "上传模组", + "loadModsFromSave": "从存档加载", + "installedTab": "已安装", + "addModTab": "添加模组", + "loadModsTab": "加载模组", + "modPacksTab": "模组包", + "selectModFile": "选择模组文件", + "selectVersion": "选择版本", + "version": "版本", + "loadModsTitle": "从存档加载模组", + "deleteExistingMods": "删除已有模组", + "loadingModList": "加载模组列表...", + "changingModsDisabled": "模组修改已禁用(服务器运行中)", + "confirmDeleteAllMods": "确认删除所有模组?此操作不可撤销。", + "noModsInstalled": "没有安装模组", + "factorioVersion": "游戏版本", + "enabled": "启用", + "compatibility": "兼容性", + "mod_version": "模组版本", + "file_size": "文件大小", + "Name": "模组名称", + "Enabled": "启用", + "Compatibility": "兼容性", + "Mod Version": "模组版本", + "Space Age DLC": "太空时代 DLC", + "File Size": "文件大小", + "readModsFromSave": "从存档读取模组", + "selectMissing": "选择缺失", + "clearSelection": "清除选择", + "syncSelected": "同步选中", + "downloadingStatus": "下载中", + "downloadedStatus": "已下载", + "installedStatus": "已安装", + "wrongVersionStatus": "版本不匹配", + "missingStatus": "缺失", + "builtinStatus": "内置/DLC", + "notFoundStatus": "门户未找到", + "failedToReadSave": "读取存档失败", + "failedToStartSync": "启动同步失败", + "mod": "模组", + "required": "需要版本", + "status": "状态", + "columns": "列", + "syncingInProgress": "模组同步中,部分操作暂时禁用", + "Game Ver": "游戏版本", + "Other Actions": "其他操作", + "active": "进行中", + "completed": "已完成", + "pending": "等待中", + "failed": "失败", + "downloading": "下载中", + "expandAll": "展开全部", + "collapseAll": "收起全部", + "enableAllMods": "启用全部模组", + "disableAllMods": "禁用全部模组" +} \ No newline at end of file diff --git a/ui/App/locales/zh-saves.json b/ui/App/locales/zh-saves.json new file mode 100644 index 00000000..7910a342 --- /dev/null +++ b/ui/App/locales/zh-saves.json @@ -0,0 +1,18 @@ +{ + "title": "存档管理", + "saves": "存档", + "actions": "操作", + "name": "名称", + "lastModifiedAt": "最后修改", + "size": "大小", + "upload": "上传", + "uploadSave": "上传存档", + "createSave": "创建存档", + "createSaveDisabled": "创建存档已禁用(服务器运行中)", + "saveFile": "存档文件", + "savefileName": "存档文件名", + "saveNameRequired": "请输入存档名称", + "selectFile": "选择文件", + "no_saves": "没有存档", + "delete_confirm": "确认删除存档?" +} \ No newline at end of file diff --git a/ui/App/locales/zh-serverSettings.json b/ui/App/locales/zh-serverSettings.json new file mode 100644 index 00000000..b46fcbbb --- /dev/null +++ b/ui/App/locales/zh-serverSettings.json @@ -0,0 +1,34 @@ +{ + "title": "配置管理", + "serverSettings": "服务器设置", + "gameSettings": "游戏设置", + "saveSettings": "保存设置", + "settingsSaved": "设置已保存", + "showTranslation": "显示说明", + "hideTranslation": "隐藏说明", + "admins": "管理员", + "name": "服务器名称", + "description": "服务器描述", + "tags": "游戏标签", + "max_players": "最大玩家数", + "visibility": "可见性", + "public": "公开", + "lan": "局域网", + "hidden": "隐藏", + "username": "用户名", + "password": "密码", + "token": "认证令牌", + "game_password": "游戏密码", + "require_user_verification": "验证用户身份", + "max_upload": "最大上传速度", + "max_upload_slots": "上传槽位数", + "minimum_latency": "最低延迟", + "ignore_player_limit": "返回玩家无视上限", + "allow_commands": "允许指令", + "auto_pause": "自动暂停", + "only_admins_can_pause": "仅管理员可暂停", + "autosave_interval": "自动保存间隔", + "autosave_slots": "自动保存槽位数", + "non_blocking_saving": "非阻塞保存", + "afk_autokick_interval": "挂机踢出时间" +} \ No newline at end of file diff --git a/ui/App/locales/zh-serverVersion.json b/ui/App/locales/zh-serverVersion.json new file mode 100644 index 00000000..d1138924 --- /dev/null +++ b/ui/App/locales/zh-serverVersion.json @@ -0,0 +1,26 @@ +{ + "title": "版本管理", + "serverVersion": "服务器版本", + "installedVersion": "已安装版本", + "availableVersions": "可用版本", + "current": "当前版本", + "stable": "稳定版", + "experimental": "实验版", + "latest": "最新", + "showLatest": "显示最新发布", + "showAll": "显示全部版本", + "unknown": "未知", + "upToDate": "已是最新", + "updateAvailable": "有新版本可用", + "updateTo": "更新到", + "install": "安装", + "installing": "安装中...", + "downloadProgress": "下载进度", + "installSuccess": "安装成功", + "installFailed": "安装失败", + "checkingVersion": "检查版本...", + "fetchFailed": "获取版本列表失败", + "noInternet": "无法连接 Factorio 服务器", + "serverMustBeStopped": "请先停止服务器", + "noVersionsAvailable": "没有可用版本" +} \ No newline at end of file diff --git a/ui/App/locales/zh-userManagement.json b/ui/App/locales/zh-userManagement.json new file mode 100644 index 00000000..fdcdcf04 --- /dev/null +++ b/ui/App/locales/zh-userManagement.json @@ -0,0 +1,26 @@ +{ + "title": "用户管理", + "name": "用户名", + "role": "角色", + "email": "邮箱", + "actions": "操作", + "listOfUsers": "用户列表", + "createUser": "添加用户", + "changePassword": "修改密码", + "change": "修改", + "save": "保存", + "remove": "删除", + "username": "用户名", + "password": "密码", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码", + "emailRequired": "请输入邮箱", + "roleRequired": "请选择角色", + "passwordConfirmation": "确认密码", + "passwordMismatch": "两次密码不一致", + "passwordChanged": "密码已修改", + "newPassword": "新密码", + "newPasswordConfirmation": "确认新密码", + "oldPassword": "旧密码", + "confirmDelete": "确认删除用户?" +} \ No newline at end of file diff --git a/ui/App/locales/zh.json b/ui/App/locales/zh.json new file mode 100644 index 00000000..8641460d --- /dev/null +++ b/ui/App/locales/zh.json @@ -0,0 +1,238 @@ +{ + "save": "保存", + "upload": "上传", + "saved": "设置已保存", + "create": "创建", + "cancel": "取消", + "confirm": "确认", + "name": "名称", + "username": "用户名", + "password": "密码", + "logout": "退出登录", + "install": "安装", + "load": "加载", + "actions": "操作", + "role": "角色", + "email": "邮箱", + "change": "修改", + "search": "搜索", + "language": "语言", + "UNKNOWN": "未知", + "main_title": "异星工厂服务器管理器", + "server_status": "服务器状态", + "server_management": "服务器管理", + "FSM_administration": "FSM 管理", + "lang": "语言", + "controls": { + "title": "控制面板", + "unknown": "未知", + "running": "运行中", + "stopped": "已停止", + "factorioVersion": "游戏版本", + "serverRunning": "服务器运行中", + "serverStopped": "服务器已停止", + "start": "启动服务器", + "stop": "停止服务器", + "kill": "强制终止", + "save_game": "保存游戏", + "ip": "IP 地址", + "port": "端口", + "saveFile": "存档", + "loadLatest": "加载最新", + "factorioInstallation": "Factorio 安装", + "installedVersion": "已安装版本", + "availableVersions": "可用版本", + "downloadInstall": "下载并安装", + "removeInstallation": "卸载" + }, + "server_settings": { + "title": "配置管理", + "admins": "管理员", + "name": "服务器名称", + "description": "服务器描述", + "tags": "游戏标签", + "max_players": "最大玩家数", + "visibility": "可见性", + "public": "公开", + "lan": "局域网", + "hidden": "隐藏", + "username": "用户名", + "password": "密码", + "token": "认证令牌", + "game_password": "游戏密码", + "require_user_verification": "验证用户身份", + "max_upload": "最大上传速度", + "max_upload_slots": "上传槽位数", + "minimum_latency": "最低延迟", + "ignore_player_limit": "返回玩家无视上限", + "allow_commands": "允许指令", + "auto_pause": "自动暂停", + "only_admins_can_pause": "仅管理员可暂停", + "autosave_interval": "自动保存间隔", + "autosave_slots": "自动保存槽位数", + "non_blocking_saving": "非阻塞保存", + "afk_autokick_interval": "挂机踢出时间", + "require_user_verification_label": "需要验证用户身份", + "visibility_label": "服务器可见性", + "show_descriptions": "显示说明", + "hide_descriptions": "隐藏说明" + }, + "saves": { + "title": "存档管理", + "dl_save": "下载存档", + "upload_save": "上传存档", + "remove_save": "删除存档", + "create_save": "创建存档", + "no_saves": "没有存档", + "delete_confirm": "确认删除存档?", + "name": "存档名称", + "lastModifiedAt": "最后修改", + "size": "大小" + }, + "mods": { + "title": "模组管理", + "installed": "已安装模组", + "portal": "模组门户", + "add": "添加模组", + "load": "从存档加载", + "delete_all": "删除全部", + "update_all": "更新全部", + "install_mods": "安装模组", + "loading_mods": "加载模组列表...", + "factorioVersion": "游戏版本", + "enabled": "启用", + "compatibility": "兼容性", + "mod_version": "模组版本", + "file_size": "文件大小", + "updateAllMods": "更新全部模组", + "downloadAllMods": "下载全部模组", + "deleteAllMods": "删除全部模组", + "confirmDeleteAll": "确认删除所有模组?此操作不可撤销。", + "noModsInstalled": "没有安装模组", + "installMods": "安装模组", + "loadingMods": "加载模组列表...", + "installedTab": "已安装", + "addModTab": "添加模组", + "loadModsTab": "加载模组", + "modPacksTab": "模组包" + }, + "modPacks": "模组包", + "createModPack": "创建模组包", + "modPackName": "模组包名称", + "game_settings": { + "title": "游戏设置" + }, + "console": { + "title": "终端", + "send": "发送", + "command": "命令", + "placeholder": "输入指令..." + }, + "logs": { + "title": "日志", + "refresh": "刷新", + "autoScroll": "自动滚动" + }, + "users": { + "title": "用户管理", + "add_user": "添加用户", + "remove": "删除" + }, + "userManagement": { + "title": "用户管理", + "addUser": "添加用户", + "username": "用户名", + "password": "密码", + "role": "角色", + "email": "邮箱", + "actions": "操作", + "confirmDelete": "确认删除用户?", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码" + }, + "help": { + "title": "帮助" + }, + "login": { + "title": "登录", + "login": "登录", + "login_failed_message": "登录失败,用户名或密码错误", + "username_error_message": "请输入用户名", + "password_error_message": "请输入密码", + "factorio_login_error_message": "用户名或密码不匹配" + }, + "factorioLogin": { + "title": "Factorio 登录", + "login_status": "登录状态", + "logged_in": "已登录", + "not_logged_in": "未登录", + "login": "登录", + "logout": "退出", + "username": "用户名", + "token": "令牌", + "token_hint": "在 factorio.com/profile 获取", + "login_success": "Factorio 登录成功", + "login_failed": "Factorio 登录失败", + "factorio_login_error_message": "用户名或密码不匹配" + }, + "serverVersion": { + "title": "版本管理", + "current": "当前版本", + "available": "可用版本", + "stable": "稳定版", + "experimental": "实验版", + "install": "安装", + "installing": "安装中...", + "latestReleases": "最新发布", + "fullVersionList": "完整版本列表", + "noVersionsAvailable": "没有可用版本" + }, + "change_password": { + "title": "修改密码", + "old_password": "旧密码", + "old_password_error": "请输入旧密码", + "new_password": "新密码", + "new_password_error": "请输入新密码", + "new_password_confirmation": "确认新密码", + "new_password_confirmation_error": "请输入确认密码" + }, + "passwordConfirmation": "确认密码", + "passwordMismatch": "两次密码不一致", + "passwordChanged": "密码已修改", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码", + "signIn": "登录", + "loginFailed": "登录失败", + "lastModifiedAt": "最后修改", + "size": "大小", + "delete_confirm": "确认删除?", + "update": "更新", + "download": "下载", + "remove": "删除", + "start": "启动", + "stop": "停止", + "kill": "强制终止", + "close": "关闭", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "submit": "提交", + "reset": "重置", + "enable": "启用", + "disable": "禁用", + "enabled": "已启用", + "disabled": "已禁用", + "updateAllMods": "更新全部模组", + "downloadAllMods": "下载全部模组", + "deleteAllMods": "删除全部模组", + "modPortal": "模组门户", + "installMod": "安装模组", + "uploadMod": "上传模组", + "loadModsFromSave": "从存档加载", + "noModsInstalled": "没有安装模组", + "installedTab": "已安装", + "addModTab": "添加模组", + "loadModsTab": "加载模组", + "modPacksTab": "模组包", + "refresh": "刷新" +} \ No newline at end of file diff --git a/ui/App/views/Console.jsx b/ui/App/views/Console.jsx index 16d633f4..ecf3f430 100644 --- a/ui/App/views/Console.jsx +++ b/ui/App/views/Console.jsx @@ -1,54 +1,79 @@ import Panel from "../components/Panel"; import React, {useEffect, useRef, useState} from "react"; +import { useTranslation } from 'react-i18next'; import socket from "../../api/socket"; +import log from "../../api/resources/log"; + +const FONT_CLASS = { small: 'text-xs', medium: 'text-sm', large: 'text-base' }; +const WIDTH = { compact: {maxWidth: '56rem'}, normal: {maxWidth: '72rem'}, full: {maxWidth: 'none'} }; const Console = ({serverStatus}) => { + const { t } = useTranslation('console'); const [logs, setLogs] = useState([]); + const [fontSize, setFontSize] = useState(() => { try { return localStorage.getItem('console_fontSize') || 'small'; } catch { return 'small'; } }); + const [wrap, setWrap] = useState(() => { try { return localStorage.getItem('console_wrap') !== 'false'; } catch { return true; } }); + const [panelWidth, setPanelWidth] = useState(() => { try { return localStorage.getItem('console_panelWidth') || 'normal'; } catch { return 'normal'; } }); const consoleInput = useRef(null); + const logEnd = useRef(null); - useEffect(() => { - - const appendLog = line => { - setLogs(lines => [...lines, line]); - } + const setFont = (size) => { setFontSize(size); try { localStorage.setItem('console_fontSize', size); } catch {} }; + const toggleWrap = () => { const n = !wrap; setWrap(n); try { localStorage.setItem('console_wrap', String(n)); } catch {} }; + const setW = (w) => { setPanelWidth(w); try { localStorage.setItem('console_panelWidth', w); } catch {} }; - socket.on('gamelog', appendLog) - socket.emit('log subscribe') + useEffect(() => { + (async () => { + try { const lines = await log.tail(); if (lines && Array.isArray(lines)) setLogs(lines); } catch {} + })(); + const appendLog = line => setLogs(lines => [...lines, line]); + socket.on('gamelog', appendLog); + socket.emit('log subscribe'); consoleInput.current?.focus(); - - return () => { - socket.off('gamelog', appendLog); - socket.emit("log unsubscribe") - } + return () => { socket.off('gamelog', appendLog); socket.emit("log unsubscribe"); }; }, []); + useEffect(() => { logEnd.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs]); + return ( - -
    - {logs?.map((log, i) => (
  • {log}
  • ))} -
- { - if (e.key === "Enter" && socket) { - socket.emit("command send", consoleInput.current.value); - consoleInput.current.value = "" - } - }} - /> - - :

- The console is not available, because Factorio is not running. -

- } - /> - ) -} +
+
+ {t('fontSize')}: + {Object.keys(FONT_CLASS).map(s => ( + + ))} + {t('wrap')}: + + {t('width')}: + {Object.keys(WIDTH).map(w => ( + + ))} +
+
+ +
+
+ {logs?.map((l, i) => (
{l}
))} +
+
+
+
+ {serverStatus.running + ? { if (e.key === "Enter" && socket) { socket.emit("command send", consoleInput.current.value); consoleInput.current.value = ""; } }}/> + :

{t('consoleNotAvailable')}

} +
+
+ }/> +
+
+ ); +}; export default Console; diff --git a/ui/App/views/Controls.jsx b/ui/App/views/Controls.jsx index 62b31d9e..f070b888 100644 --- a/ui/App/views/Controls.jsx +++ b/ui/App/views/Controls.jsx @@ -1,26 +1,41 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect, useState, useRef} from "react"; +import { useTranslation } from 'react-i18next'; import Panel from "../components/Panel"; import Button from "../components/Button"; import server from "../../api/resources/server"; import savesResource from "../../api/resources/saves"; +import modsResource from "../../api/resources/mods"; import {useForm} from "react-hook-form"; import Select from "../components/Select"; import Input from "../components/Input"; import Error from "../components/Error"; +import socket from "../../api/socket"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faUsers, faClock, faMicrochip, faMemory, faComments} from "@fortawesome/free-solid-svg-icons"; const Controls = ({serverStatus}) => { - const factorioVersion = serverStatus.fac_version ? serverStatus.fac_version : 'Unknown'; + const { t } = useTranslation('controls'); + const savedIp = localStorage.getItem('fsm_ip') || '0.0.0.0'; + const savedPort = localStorage.getItem('fsm_port') || '34197'; + const factorioVersion = serverStatus.fac_version ? serverStatus.fac_version : t('UNKNOWN'); const [saves, setSaves] = useState([]); const [isDisabled, setIsDisabled] = useState(true); const [isStopping, setIsStopping] = useState(false); const [isStarting, setIsStarting] = useState(false); const [isKilling, setIsKilling] = useState(false); + const [chatMessages, setChatMessages] = useState([]); + const [modStats, setModStats] = useState({total: 0, compat: 0, incompat: 0}); + const [uptime, setUptime] = useState(0); + const [players, setPlayers] = useState([]); + const chatEnd = useRef(null); const { handleSubmit, reset, register, formState: {errors} } = useForm(); const startServer = async (data) => { setIsStarting(true); + localStorage.setItem('fsm_ip', data.ip); + localStorage.setItem('fsm_port', data.port); await server.start(data.ip, parseInt(data.port), data.save); } @@ -38,105 +53,178 @@ const Controls = ({serverStatus}) => { savesResource.list(true) .then(res => { setSaves(res); - if (res.length > 0) { - setIsDisabled(undefined); - } + if (res.length > 0) setIsDisabled(undefined); reset(); }); - }, []) + modsResource.installed().then(mods => { + setModStats({ + total: mods.length, + compat: mods.filter(m => m.compatibility).length, + incompat: mods.filter(m => !m.compatibility).length + }); + }); + }, []); + + useEffect(() => { + const handler = (msg) => { + const text = String(msg); + if (text.includes('[CHAT]')) { + setChatMessages(prev => [...prev.slice(-49), text.replace(/^.*\[CHAT\]\s*/, '')]); + } + if (text.includes('joined the game')) { + const name = text.match(/\[JOIN\]\s*(\S+)/)?.[1] || ''; + if (name) setPlayers(prev => prev.includes(name) ? prev : [...prev, name]); + } + if (text.includes('left the game')) { + const name = text.match(/\[LEAVE\]\s*(\S+)/)?.[1] || ''; + if (name) setPlayers(prev => prev.filter(p => p !== name)); + } + }; + socket.on('gamelog', handler); + return () => socket.off('gamelog', handler); + }, []); + + useEffect(() => { + if (!serverStatus.running) return; + setUptime(0); + const timer = setInterval(() => setUptime(u => u + 1), 1000); + return () => clearInterval(timer); + }, [serverStatus.running]); + + useEffect(() => { chatEnd.current?.scrollIntoView({behavior:'smooth'}); }, [chatMessages]); + + const fmtTime = (s) => { + const h = Math.floor(s/3600), m = Math.floor(s%3600/60), sec = s%60; + return `${h}h ${m}m ${sec}s`; + }; return ( -
- - { serverStatus.running - ? <> -
-
Status
-
{serverStatus.running ? 'Running' : 'Stopped'}
-
-
-
IP
-
{serverStatus.bindip}
-
-
-
Port
-
{serverStatus.port}
-
-
-
Factorio Version
-
{factorioVersion}
-
-
-
Save
-
{serverStatus.savefile}
-
- - : <> -
-
Status
-
{serverStatus.running ? 'Running' : 'Stopped'}
-
-
-
IP
- - -
-
-
Port
- - -
-
-
Factorio Version
-
{factorioVersion}
+
+ {/* Status Banner */} + {serverStatus.running ? ( +
+
+
+ {t('RUNNING')} + {fmtTime(uptime)} +
+
+ {players.length} {t('players') || 'players'} + {serverStatus.bindip}:{serverStatus.port} +
+
+ ) : ( +
+
+ {t('STOPPED')} +
+ )} + + {/* Info Cards */} +
+
+ +
{factorioVersion}
+
{t('factorioVersion')}
+
+
+ +
{players.length}
+
{t('players') || 'players'}
+
+
+ +
{serverStatus.running ? fmtTime(uptime) : '--'}
+
{t('uptime') || 'uptime'}
+
+
+
{modStats.total}
+
{modStats.compat} {t('compatible') || 'compatible'}
+
{modStats.incompat} {t('incompatible') || 'incompatible'}
+
+
+ + {/* Players List */} + {serverStatus.running && players.length > 0 && ( + + {players.map(p => ( + {p} + ))} +
+ }/> + )} + + {/* Server Controls */} + + +
{t('ip')}: {serverStatus.bindip}
+
{t('port')}: {serverStatus.port}
+
{t('factorioVersion')}: {factorioVersion}
+
{t('save')}: {serverStatus.savefile}
-
-
Save
-
- + +
+
+
{t('port')}
+ + +
+
+
{t('factorioVersion')}
+
{factorioVersion}
+
+
+
{t('save')}
+ - +
-
- +
} diff --git a/ui/App/views/Logs.jsx b/ui/App/views/Logs.jsx index e416402a..5ecd0022 100644 --- a/ui/App/views/Logs.jsx +++ b/ui/App/views/Logs.jsx @@ -1,9 +1,12 @@ import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import log from "../../api/resources/log"; import Panel from "../components/Panel"; const Logs = () => { + const { t } = useTranslation('logs'); + const [logs, setLogs] = useState([]) useEffect(() => { @@ -15,7 +18,7 @@ const Logs = () => { return ( {logs.map((log,index) => (
  • {log}
  • ))} diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 6376ae0a..6dd0cb74 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -1,8 +1,10 @@ import Panel from "../../components/Panel"; import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import modsResource from "../../../api/resources/mods"; import Button from "../../components/Button"; import server from "../../../api/resources/server"; +import socket from "../../../api/socket"; import TabControl from "../../components/Tabs/TabControl"; import Tab from "../../components/Tabs/Tab"; import AddMod from "./components/AddMod/AddMod"; @@ -12,28 +14,41 @@ import Fuse from "fuse.js"; import CreateModPack from "./components/CreateModPack"; import ModPack from "./components/ModPack"; import ModList from "./components/ModList"; +import ConfirmDialog from "../../components/ConfirmDialog"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinner } from "@fortawesome/free-solid-svg-icons"; const Mods = ({serverStatus}) => { + const { t } = useTranslation('mods'); + const [installedMods, setInstalledMods] = useState([]); const [modPacks, setModPacks] = useState([]) const [factorioVersion, setFactorioVersion] = useState(null); const [fuse, setFuse] = useState(undefined); + const [portalLoading, setPortalLoading] = useState(false); const [isDeletingAllMods, setIsDeletingAllMods] = useState(false); const [isUpdatingAllMods, setIsUpdatingAllMods] = useState(false); const [updatableMods, setUpdatableMods] = useState([]); + const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false); + const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); + const [authChecked, setAuthChecked] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const addUpdatableMod = mod => { - setUpdatableMods(mods => [...mods, mod]) + setUpdatableMods(mods => { + if (mods.some(m => m.modName === mod.modName)) return mods; + return [...mods, mod]; + }); }; const fetchInstalledMods = () => { - modsResource.installed() + return modsResource.installed() .then(setInstalledMods); }; const fetchModPacks = () => { - modsResource.packs.list() + return modsResource.packs.list() .then(setModPacks) } @@ -57,6 +72,13 @@ const Mods = ({serverStatus}) => { .finally(() => setIsUpdatingAllMods(false)); } + useEffect(() => { + modsResource.portal.status().then(auth => { + setIsFactorioAuthenticated(auth); + setAuthChecked(true); + }); + }, []); + useEffect(() => { server.factorioVersion() .then(data => { @@ -65,24 +87,44 @@ const Mods = ({serverStatus}) => { fetchModPacks(); }) - // fetch list of mods + setPortalLoading(true); modsResource.portal.list() .then(res => { setFuse(new Fuse(res.results, { - keys: [ - { - "name": "name", - weight: 2 - }, - { - "name": "title", - weight: 1 - } - ], + keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], minMatchCharLength: 3 })); - }); + }).finally(() => setPortalLoading(false)); + + const interval = setInterval(() => { + if (document.hidden) return; + fetchInstalledMods().catch(() => {}); + fetchModPacks().catch(() => {}); + }, 2000); + const handleModEvent = (message) => { + const data = JSON.parse(message); + if (data.type === "mod_deleted") { + setInstalledMods(prev => prev.filter(m => m.name !== data.name)); + } else if (data.type === "mods_cleared") { + setInstalledMods([]); + } + }; + socket.on('mods_events', handleModEvent); + socket.emit('mods events subscribe'); + + const handleRefresh = () => { + fetchInstalledMods().catch(() => {}); + fetchModPacks().catch(() => {}); + }; + window.addEventListener('fsm_refresh_mods', handleRefresh); + + return () => { + clearInterval(interval); + socket.off('mods_events', handleModEvent); + socket.emit('mods events unsubscribe'); + window.removeEventListener('fsm_refresh_mods', handleRefresh); + }; }, []); const toggleMod = modName => { @@ -103,33 +145,79 @@ const Mods = ({serverStatus}) => { .then(fetchInstalledMods) } + const enableAllMods = () => { + const toEnable = installedMods.filter(m => !m.enabled && m.name !== 'base'); + Promise.all(toEnable.map(m => toggleMod(m.name))) + .then(fetchInstalledMods) + .catch(() => fetchInstalledMods()); + } + + const disableAllMods = () => { + const toDisable = installedMods.filter(m => m.enabled && m.name !== 'base'); + Promise.all(toDisable.map(m => toggleMod(m.name))) + .then(fetchInstalledMods) + .catch(() => fetchInstalledMods()); + } + let disabled = serverStatus.running + let isBusy = disabled || isDeletingAllMods || isUpdatingAllMods || isSyncing return (
    - {disabled ? + {disabled && !isSyncing && - Changing mods is disabled while the server is running! -
    +
    + {t('changingModsDisabled')} +
    } /> - : - - - - - - - - - - - + } + {isSyncing && + +
    + {t('syncingInProgress')} +
    + +
    + } + /> + } + {!authChecked ? null : +
    + {portalLoading && +
    +
    + + {t('loadingModList')} +
    +
    + } +
    + + + + + + + + + + + +
    +
    } { } actions={ <> - { - !disabled && - && + { + !isBusy && + onClick={updateAllMods}>{t('updateAllMods')} } - Download all Mods + {!isBusy ? ( + <> + {t('downloadAllMods')} + + + + + ) : ( + {t('changingModsDisabled')} + )} } /> { } /> + setShowDeleteAllConfirm(false)} + onSuccess={() => { + setShowDeleteAllConfirm(false); + deleteAllMods(); + }} + closeImmediately={true} + />
    ) } diff --git a/ui/App/views/Mods/components/AddMod/AddMod.jsx b/ui/App/views/Mods/components/AddMod/AddMod.jsx index 9a645566..4a0152ca 100644 --- a/ui/App/views/Mods/components/AddMod/AddMod.jsx +++ b/ui/App/views/Mods/components/AddMod/AddMod.jsx @@ -1,23 +1,12 @@ -import React, {useEffect, useState} from "react"; +import React from "react"; import AddModForm from "./components/AddModForm"; import FactorioLogin from "./components/FactorioLogin"; -import modResource from "../../../../../api/resources/mods"; - - -const AddMod = ({refetchInstalledMods, fuse}) => { - - const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); - - useEffect(() => { - (async () => { - setIsFactorioAuthenticated(await modResource.portal.status()) - })(); - }, []); +const AddMod = ({refetchInstalledMods, fuse, loading, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { return isFactorioAuthenticated - ? + ? : } -export default AddMod; \ No newline at end of file +export default AddMod; diff --git a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx index c5a75f2d..505c6adb 100644 --- a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx +++ b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx @@ -1,4 +1,5 @@ import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import modsResource from "../../../../../../api/resources/mods"; import Button from "../../../../../components/Button"; import Label from "../../../../../components/Label"; @@ -10,12 +11,13 @@ import {faSpinner} from "@fortawesome/free-solid-svg-icons"; import SelectVersionForm from "./SelectVersionForm"; const LinkModPortal = () => { - return Mod - Portal + const { t } = useTranslation('mods'); + return {t('modPortal')} } -const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods}) => { +const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods, loading}) => { + const { t } = useTranslation('mods'); const {register, watch, setValue, handleSubmit} = useForm(); const [suggestedMods, setSuggestedMods] = useState([]); const [selectedMod, setSelectedMod] = useState(null); @@ -101,11 +103,11 @@ const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods}) =>
    setIsModalOpen(false)}/>
    -
    - - + + ) diff --git a/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx b/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx index 7450333e..193d2885 100644 --- a/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx +++ b/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx @@ -10,9 +10,9 @@ const FactorioLogin = ({setIsFactorioAuthenticated}) => { const {register, handleSubmit} = useForm(); const [isLoading, setIsLoading] = useState(false); - const login = ({username, password}) => { + const login = ({username, token}) => { setIsLoading(true); - modsResource.portal.login(username, password) + modsResource.portal.login(username, token) .then(res => { setIsFactorioAuthenticated(true) }) @@ -28,8 +28,8 @@ const FactorioLogin = ({setIsFactorioAuthenticated}) => {
    -
    diff --git a/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx b/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx index dd2815f9..2569b7a1 100644 --- a/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx +++ b/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from 'react-i18next'; import Modal from "../../../../../components/Modal"; import Button from "../../../../../components/Button"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; @@ -8,6 +9,8 @@ import {faTimes} from "@fortawesome/free-solid-svg-icons/faTimes"; const SelectVersionForm = ({releases, isOpen, close, install}) => { + const { t } = useTranslation(['mods', 'common']); + const download = release => { install(release) close() @@ -16,15 +19,15 @@ const SelectVersionForm = ({releases, isOpen, close, install}) => { return ( - - - + + + @@ -45,7 +48,7 @@ const SelectVersionForm = ({releases, isOpen, close, install}) => { } actions={ - + } /> ) diff --git a/ui/App/views/Mods/components/CreateModPack.jsx b/ui/App/views/Mods/components/CreateModPack.jsx index 94262200..5323ba49 100644 --- a/ui/App/views/Mods/components/CreateModPack.jsx +++ b/ui/App/views/Mods/components/CreateModPack.jsx @@ -1,4 +1,5 @@ import React, {useState} from "react"; +import { useTranslation } from 'react-i18next'; import Button from "../../../components/Button"; import Modal from "../../../components/Modal"; import Label from "../../../components/Label"; @@ -8,6 +9,8 @@ import modsResource from "../../../../api/resources/mods"; const CreateModPack = ({onSuccess}) => { + const { t } = useTranslation(['mods', 'common']); + const [isCreating, setIsCreating] = useState(false); const [isOpen, setIsOpen] = useState(false); @@ -26,18 +29,18 @@ const CreateModPack = ({onSuccess}) => { } return <> - - setIsOpen(true)}>{t('createModPack')} +
    -
    - + } actions={ - + } /> diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 331c3527..08e6fba4 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -1,83 +1,511 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect, useState, useRef} from "react"; import savesResource from "../../../../api/resources/saves"; -import Select from "../../../components/Select"; import Label from "../../../components/Label"; -import {useForm} from "react-hook-form"; import Button from "../../../components/Button"; import modsResource from "../../../../api/resources/mods"; -import modResource from "../../../../api/resources/mods"; import FactorioLogin from "./AddMod/components/FactorioLogin"; -import ConfirmDialog from "../../../components/ConfirmDialog"; +import socket from "../../../../api/socket"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {useTranslation} from "react-i18next"; +import {faSpinner, faCheck, faTimes, faMinusCircle, faChevronDown, faChevronUp, faClock} from "@fortawesome/free-solid-svg-icons"; -const LoadMods = ({refreshMods}) => { +const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); +const STATUS_ICON = { + downloading: , + downloaded: , + installed: , + wrong_version: , + missing: , + builtin: , + not_found: , +}; + +const formatSize = (bytes) => { + if (!bytes || bytes < 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0, v = bytes; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +}; + +const formatTime = (seconds) => { + if (!seconds || seconds < 0 || !isFinite(seconds)) return '--:--'; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +}; + +const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated, onSyncingChange}) => { + const {t} = useTranslation('mods'); const [saves, setSaves] = useState([]); - const {register, reset, handleSubmit} = useForm(); + const [selectedSave, setSelectedSave] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const [isDisabled, setIsDisabled] = useState(true); - const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); - const [loadModsData, setLoadModsData] = useState(undefined); + const [modRows, setModRows] = useState([]); + const [checkedMods, setCheckedMods] = useState({}); + const [syncError, setSyncError] = useState(null); + const [warning, setWarning] = useState(null); + const [modProgress, setModProgress] = useState({}); + const [expanded, setExpanded] = useState(false); + const [tick, setTick] = useState(0); + const recoveringRef = useRef(false); + const modRowsRef = useRef(modRows); + modRowsRef.current = modRows; + const delayTimers = useRef({}); useEffect(() => { - (async () => { - setIsFactorioAuthenticated(await modResource.portal.status()) + if (!isSyncing) return; + const id = setInterval(() => setTick(t => t + 1), 500); + return () => clearInterval(id); + }, [isSyncing]); - const s = await savesResource.list() + useEffect(() => { + (async () => { + if (!isFactorioAuthenticated) { + setIsFactorioAuthenticated(await modsResource.portal.status()); + } + const s = await savesResource.list(); setSaves(s); if (s.length > 0) { setIsDisabled(false); + setSelectedSave(s[0].name); } - reset(); })(); }, []); - const loadModsRequested = data => { + useEffect(() => { + const handler = (message) => { + const data = JSON.parse(message); + if (modRowsRef.current.length === 0 && data.status && data.status !== "done" && data.status !== "error") { + if (recoveringRef.current) return; + recoveringRef.current = true; + const saved = sessionStorage.getItem('fsm_sync_save'); + if (saved && selectedSave !== saved) { + setSelectedSave(saved); + } + setIsSyncing(true); + if (onSyncingChange) onSyncingChange(true); + if (saved) { + modsResource.getFromSave(saved).then(mods => { + setModRows(prev => { + if (prev.length > 0) return prev; + return mods || []; + }); + }).catch(() => {}).finally(() => { + recoveringRef.current = false; + }); + } else { + recoveringRef.current = false; + } + } + if (data.status === "progress") { + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "downloading"} : r + )); + + const now = Date.now(); + const bytes = data.downloaded || 0; + + setModProgress(prev => { + const existing = prev[data.mod] || {}; + const startedAt = existing.startedAt || now; + const elapsed = (now - startedAt) / 1000; + const speed = elapsed > 0.1 ? Math.round(bytes / elapsed) : 0; + + return { + ...prev, + [data.mod]: { + downloaded: bytes, + size: data.size || existing.size || 0, + speed, + startedAt, + } + }; + }); + } else if (data.status === "downloaded") { + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "downloaded"} : r + )); + if (delayTimers.current[data.mod]) clearTimeout(delayTimers.current[data.mod]); + delayTimers.current[data.mod] = setTimeout(() => { + setModRows(rows => rows.map(r => + r.name === data.mod && r.status === "downloaded" ? {...r, status: "done_delayed"} : r + )); + delete delayTimers.current[data.mod]; + }, 2000); + } else if (data.status === "not_found") { + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "not_found"} : r + )); + } else if (data.status === "builtin") { + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "builtin"} : r + )); + } else if (data.status === "version_mismatch") { + let latestInfo = {latest: '?', downloadUrl: '', fileName: ''}; + try { latestInfo = JSON.parse(data.message || '{}'); } catch(e) {} + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "version_mismatch", latestInfo} : r + )); + } else if (data.status === "done") { + setIsSyncing(false); + if (onSyncingChange) onSyncingChange(false); + sessionStorage.removeItem('fsm_sync_save'); + setWarning(data.warning || null); + setExpanded(false); + if (data.mods) { + setModRows(data.mods); + setCheckedMods({}); + } + refreshMods(); + } else if (data.status === "error") { + setIsSyncing(false); + if (onSyncingChange) onSyncingChange(false); + setSyncError(data.message); + setExpanded(false); + } + }; + + socket.on('mods_sync', handler); + socket.emit('mods sync subscribe'); + return () => { + socket.off('mods_sync', handler); + socket.emit('mods sync unsubscribe'); + recoveringRef.current = false; + Object.values(delayTimers.current).forEach(clearTimeout); + delayTimers.current = {}; + if (onSyncingChange) onSyncingChange(false); + }; + }, []); + + const onReadSave = async () => { + if (!selectedSave) return; setIsLoading(true); - setLoadModsData(data); - } + setModRows([]); + setCheckedMods({}); + setSyncError(null); + setWarning(null); + setModProgress({}); + setExpanded(false); - const loadMods = async data => { - await modResource.deleteAll(); - const {mods} = await savesResource.mods(data.save).catch(() => { + try { + const mods = await modsResource.getFromSave(selectedSave); + setModRows(mods || []); + const checked = {}; + (mods || []).forEach(m => { + if (m.status === 'missing' || m.status === 'wrong_version') { + checked[m.name] = true; + } + }); + setCheckedMods(checked); + } catch(e) { + setSyncError(t('failedToReadSave') + ': ' + e.message); + } finally { setIsLoading(false); - setLoadModsData(undefined); + } + }; + + const onSync = async () => { + const toSync = Object.keys(checkedMods).filter(k => checkedMods[k]); + if (toSync.length === 0) return; + + setIsSyncing(true); + if (onSyncingChange) onSyncingChange(true); + sessionStorage.setItem('fsm_sync_save', selectedSave); + setSyncError(null); + setExpanded(false); + try { + await modsResource.syncFromSave(selectedSave, toSync); + } catch (e) { + setSyncError(t('failedToStartSync') + ': ' + e.message); + setIsSyncing(false); + if (onSyncingChange) onSyncingChange(false); + } + }; + + const selectMissing = () => { + const checked = {}; + modRows.forEach(m => { + if (m.status !== 'builtin' && m.status !== 'installed') { + checked[m.name] = true; + } }); + setCheckedMods(checked); + }; - await modResource.portal.installMultiple(mods) - .then(() => { - refreshMods(); - window.flash(`Mods are loaded from save file ${data.save}.`, "green"); - }).finally(() => { - setIsLoading(false); - setLoadModsData(undefined); - }); + const clearAll = () => setCheckedMods({}); + + const checkedCount = Object.values(checkedMods).filter(Boolean).length; + + if (!isFactorioAuthenticated) { + return ; + } + + if (isSyncing && modRows.length > 0) { + const now = Date.now(); + const downloading = modRows.filter(r => r.status === 'downloading'); + const downloaded = modRows.filter(r => r.status === 'downloaded'); + const failed = modRows.filter(r => r.status === 'not_found'); + const total = modRows.length; + const pending = total - downloading.length - downloaded.length - failed.length; + + const sorted = [...modRows].sort((a, b) => { + const o = {downloading: 0, downloaded: 1}; + return (o[a.status] ?? 2) - (o[b.status] ?? 2); + }); + + return ( +
    +
    VersionCompatibilityActions{t('version', { ns: 'mods' })}{t('compatibility', { ns: 'mods' })}{t('actions', { ns: 'common' })}
    + + + + + + + + + + + + {modRows.map(mod => ( + + + + + + + + + ))} + +
    {t('mod')}{t('required')}{t('installed')}{t('status')}
    + {mod.status === 'builtin' ? null : ( + { + setCheckedMods(prev => ({ + ...prev, + [mod.name]: !prev[mod.name] + })); + }} + /> + )} + + {mod.name} + {DLC_MODS.has(mod.name) && ( + DLC + )} + {mod.version_required || mod.version}{mod.version_installed || '-'} + + {STATUS_ICON[mod.status] || mod.status} + + + {mod.status === 'version_mismatch' && mod.latestInfo && ( + + )} + {mod.status !== 'version_mismatch' && mod.file_size > 0 && ( + {((mod.file_size || 0) / 1024).toFixed(0)} KB + )} +
    + + )} + + ); +}; export default LoadMods; diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index 77420482..3f187108 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -1,117 +1,202 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import { - faArrowCircleUp, - faCheck, - faSpinner, - faTimes, - faToggleOff, - faToggleOn, - faTrashAlt + faArrowCircleUp, faCheck, faSpinner, faTimes, + faToggleOff, faToggleOn, faTrashAlt, faCaretDown, faCaretRight, + faUpRightFromSquare, faDownload, faRotate, faEye } from "@fortawesome/free-solid-svg-icons"; import modsResource from "../../../../api/resources/mods"; import React, {useEffect, useState} from "react"; import {coerce, gt, satisfies} from "semver"; -const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatableMod, disabled = false}) => { +const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age', 'base']); +const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatableMod, disabled = false, visibleCols, index, allMods, tableMode}) => { + const isDLC = DLC_MODS.has(mod.name); const [newVersion, setNewVersion] = useState(null) const [icon, setIcon] = useState(faArrowCircleUp) + const [checked, setChecked] = useState(false) + const [safeDepsOpen, setSafeDepsOpen] = useState(false) + const [repairing, setRepairing] = useState(false) + const [safeDeps, setSafeDeps] = useState([]) useEffect(() => { - if (!disabled) { + try { + const raw = mod.dependencies; + if (Array.isArray(raw)) setSafeDeps(raw.filter(d => typeof d === 'string' && d && parseDepName(d) !== 'base')); + else setSafeDeps([]); + } catch(e) { setSafeDeps([]); } + }, [mod]); + + useEffect(() => { + if (!disabled && !isDLC && !checked) { + setChecked(true); (async () => { - const data = await modsResource.portal.info(mod.name) - - //get newest COMPATIBLE release - let newestRelease; - data.releases.forEach(release => { - if ( - gt( - coerce(release.version), - coerce(mod.version) - ) && ( + try { + const data = await modsResource.portal.info(mod.name) + let newestRelease; + (data.releases || []).forEach(release => { + if (gt(coerce(release.version), coerce(mod.version)) && ( satisfies(factorioVersion, "~" + coerce(release.info_json.factorio_version).version) || - ( - satisfies(factorioVersion, "1.0.0") && - satisfies(coerce(release.info_json.factorio_version), "0.18.x") - ) - ) - ) { - if (!newestRelease) { - newestRelease = release; - } else if (gt(coerce(release.version).version, coerce(newestRelease.version).version)) { - newestRelease = release; + (satisfies(factorioVersion, "1.0.0") && satisfies(coerce(release.info_json.factorio_version), "0.18.x")) + )) { + if (!newestRelease || gt(coerce(release.version).version, coerce(newestRelease.version).version)) + newestRelease = release; } - } - }); - - if (newestRelease && newestRelease.version !== mod.version) { - const installableVersion = { - downloadUrl: newestRelease.download_url, - fileName: newestRelease.file_name, - modName: mod.name - } - setNewVersion(installableVersion); - if (addUpdatableMod !== null) { - addUpdatableMod(installableVersion) - } - } else { - setNewVersion(null); - } - + }); + if (newestRelease && newestRelease.version !== mod.version) { + const v = {downloadUrl: newestRelease.download_url, fileName: newestRelease.file_name, modName: mod.name}; + setNewVersion(v); + if (addUpdatableMod !== null) addUpdatableMod(v); + } else setNewVersion(null); + } catch (e) {} })(); } }, [mod]); + const vis = visibleCols || ['compatibility','enabled','name','version','factorio']; + + if (tableMode) { + const isDLC = DLC_MODS.has(mod.name); + const isDepInstalled = (depName) => Array.isArray(allMods) && allMods.some(m => m.name === depName); + return ( +
    + {mod.title} + {safeDeps.length > 0 && ( + setSafeDepsOpen(!safeDepsOpen)}> + + {safeDeps.length} + + )} + {safeDepsOpen && safeDeps.length > 0 && ( +
    + {safeDeps.map((d, i) => { + const name = parseDepName(d); + const inst = isDepInstalled(name); + return (
    {d.trim()}{inst ? { scrollToMod(name); setSafeDepsOpen(false); }}/> : window.open('https://mods.factorio.com/mod/' + name, '_blank')}/>}
    ); + })} +
    + )} +
    + ); + } + + + const stripVersion = (v) => { + if (!v) return v; + const parts = v.split('.'); + if (parts.length === 4 && parts[3] === '0') parts.pop(); + return parts.join('.'); + }; + + const parseDepName = (dep) => { + if (typeof dep !== 'string') return ''; + const parts = dep.split(/\s+/); + const first = parts[0] || ''; + return first.replace(/^[?!()]+/, '').replace(/[?!()]+$/, ''); + }; + + const isDepInstalled = (depName) => Array.isArray(allMods) && allMods.some(m => m.name === depName); + + const scrollToMod = (name) => { + const el = document.getElementById('mod-row-' + name.replace(/[^a-zA-Z0-9_-]/g, '')); + if (el) { el.scrollIntoView({behavior:'smooth',block:'center'}); el.classList.add('mod-flash'); setTimeout(() => el.classList.remove('mod-flash'), 2000); } + }; + + const repairMod = async () => { + setRepairing(true); + try { + const info = await modsResource.portal.info(mod.name); + const release = info.releases.find(r => r.version === mod.version) || info.releases[info.releases.length - 1]; + if (release) await updateMod({downloadUrl: release.download_url, fileName: release.file_name, modName: mod.name}); + } catch (e) {} + setRepairing(false); + }; + + const downloadMod = () => window.open('https://mods.factorio.com' + (newVersion?.downloadUrl || '/mod/' + mod.name), '_blank'); + + // Data-driven cell rendering — column key → render function + const renderCell = (colKey) => { + switch (colKey) { + case 'compatibility': + return ( + + {mod.compatibility ? : } + + ); + case 'enabled': + return ( + + {isDLC ? (mod.enabled ? : ) + : disabled ? (mod.enabled ? : ) + : mod.enabled ? toggleMod(mod.name)}/> + : toggleMod(mod.name)}/>} + + ); + case 'name': + return ( + + {mod.title} + {safeDeps.length > 0 && ( + setSafeDepsOpen(!safeDepsOpen)}> + + {safeDeps.length} + + )} + {safeDepsOpen && safeDeps.length > 0 && ( +
    + {safeDeps.map((d, i) => { + const name = parseDepName(d); + const inst = isDepInstalled(name); + return ( +
    + {d.trim()} + {inst + ? { scrollToMod(name); setSafeDepsOpen(false); }}/> + : window.open('https://mods.factorio.com/mod/' + name, '_blank')}/>} +
    + ); + })} +
    + )} + + ); + case 'version': + return ( + + {mod.version} + {!disabled && newVersion && { setIcon(faSpinner); updateMod(newVersion).finally(() => setIcon(faArrowCircleUp)); }} className="hover:text-orange cursor-pointer ml-1" icon={icon}/>} + + ); + case 'factorio': + return ( + + {'≥ ' + stripVersion(mod.factorio_version)} + + ); + case 'size': + return ( + + {mod.file_size > 0 ? ((mod.file_size / 1024).toFixed(0) + ' KB') : '-'} + + ); + default: + return null; + } + }; + return ( - - {mod.title} - - { - disabled - ? - - mod.enabled - ? - : - : - mod.enabled - ? toggleMod(mod.name)}/> - : - toggleMod(mod.name)}/> - } - - - {mod.compatibility - ? - : - } - - - {mod.version} - {!disabled && newVersion && { - setIcon(faSpinner) - updateMod(newVersion) - .finally(() => setIcon(faArrowCircleUp)) - }} - className="hover:text-orange cursor-pointer ml-1" - icon={icon}/>} - {mod.factorio_version} - { - !disabled && - - deleteMod(mod.name)} icon={faTrashAlt}/> - - } + + {index} + {vis.map(key => renderCell(key))} + {!isDLC && !disabled && ( + + window.open('https://mods.factorio.com/mod/' + mod.name, '_blank')}/> + + + deleteMod(mod.name)}/> + )} ) } export default Mod; - diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index 1cb67270..75feda4e 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -1,38 +1,352 @@ +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faCog, faSort, faSortUp, faSortDown, faCheck, faTimes, faToggleOn, faToggleOff, faUpRightFromSquare, faDownload, faRotate, faTrashAlt} from "@fortawesome/free-solid-svg-icons"; import Mod from "./Mod"; -import React from "react"; +import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"; +import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender } from "@tanstack/react-table"; +import { useTranslation } from 'react-i18next'; +const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); +const INIT_VIS = { compatibility: true, enabled: true, name: true, version: true, factorio: true, size: false, actions: true }; +const loadVis = () => { try { const s = localStorage.getItem('modlist_cols_v3'); if (s) { const v = JSON.parse(s); v.name = true; return v; } } catch {} return INIT_VIS; }; +const loadWidths = () => { try { return JSON.parse(localStorage.getItem('modlist_widths_v3') || '{}'); } catch { return {}; } }; +const saveWidths = (w) => localStorage.setItem('modlist_widths_v3', JSON.stringify(w)); -const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpdatableMod = null, disabled = false}) => { +const COLS_ORDER = Object.keys(INIT_VIS).filter(k => k !== 'actions'); +const DEF_SIZES = { compatibility: 80, enabled: 70, name: 200, version: 100, factorio: 140, size: 80, actions: 110 }; +const MIN_PAD = 28; +const ICON_WIDTH = 16; +const IDX_WIDTH = 40; - return ( - - - - - - - - - - - - { - factorioVersion !== null && mods.map( - (mod, i) => - - ) +const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpdatableMod, disabled = false}) => { + const { t, i18n } = useTranslation('mods'); + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useState(loadVis); + const [columnOrder, setColumnOrder] = useState(() => { + const saved = localStorage.getItem('modlist_colorder_v2'); + return saved ? JSON.parse(saved) : COLS_ORDER; + }); + const [showSettings, setShowSettings] = useState(false); + const [resizing, setResizing] = useState(null); + const [dragOverCol, setDragOverCol] = useState(null); + const [autoFitReady, setAutoFitReady] = useState(false); + const [, setTick] = useState(0); + const dragCol = useRef(null); + const colWidthsRef = useRef(loadWidths()); + const contentMinRef = useRef({}); + const containerRef = useRef(null); + const persistWidths = useCallback(() => saveWidths(colWidthsRef.current), []); + const settingsRef = useRef(null); + + useEffect(() => { + const handler = (e) => { if (settingsRef.current && !settingsRef.current.contains(e.target)) setShowSettings(false); }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const dlcMods = useMemo(() => mods.filter(m => DLC_MODS.has(m.name)), [mods]); + const regularMods = useMemo(() => mods.filter(m => !DLC_MODS.has(m.name)), [mods]); + const sortedMods = useMemo(() => { + if (sorting.length === 0) return regularMods; + const { id, desc } = sorting[0]; + return [...regularMods].sort((a, b) => { + const va = a[id], vb = b[id]; + if (va == null && vb == null) return 0; + if (va == null) return desc ? -1 : 1; + if (vb == null) return desc ? 1 : -1; + const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true }); + return desc ? -cmp : cmp; + }); + }, [regularMods, sorting]); + + const doMeasure = useCallback(() => { + if (sortedMods.length === 0) return; + const parent = containerRef.current || document.body; + const run = () => { + const span = document.createElement('span'); + span.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit'; + parent.appendChild(span); + const m = (text) => { span.textContent = text; return span.offsetWidth; }; + const saved = colWidthsRef.current; + const cm = {}; + + cm.compatibility = m(t('Compatibility')) + ICON_WIDTH + 4; + cm.enabled = m(t('Enabled')) + ICON_WIDTH + 4; + cm.version = m(t('Mod Version')) + ICON_WIDTH + 4; + for (const mod of sortedMods) { const w = m(mod.version || ''); if (w > cm.version) cm.version = w; } + cm.factorio = m(t('Game Ver')); + for (const mod of sortedMods) { + const v = mod.factorio_version || ''; + const parts = String(v).split('.'); while (parts.length > 2 && parts[parts.length-1] === '0') parts.pop(); + const w = m('≥ ' + parts.join('.')); if (w > cm.factorio) cm.factorio = w; + } + cm.actions = m(t('Other Actions')); + cm.size = m(t('File Size')) + ICON_WIDTH + 4; + for (const mod of sortedMods) { const label = mod.file_size > 0 ? ((mod.file_size/1024).toFixed(0)+' KB') : '-'; const w = m(label); if (w > cm.size) cm.size = w; } + cm.name = ICON_WIDTH + 4; + for (const mod of sortedMods) { const w = m(mod.title || mod.name || ''); if (w > cm.name) cm.name = w; } + parent.removeChild(span); + + contentMinRef.current = cm; + const nw = { ...saved }; + for (const [colId, minW] of Object.entries(cm)) { + if (!saved[colId]) nw[colId] = minW + MIN_PAD; } - -
    NameEnabledCompatibilityMod VersionFactorio Version -
    - ) -} + colWidthsRef.current = nw; + setAutoFitReady(true); + }; + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(run); + } else { + run(); + } + }, [sortedMods, t]); + + useEffect(() => { doMeasure(); }, [doMeasure]); + + useEffect(() => { + let timer; + const onResize = () => { + clearTimeout(timer); + timer = setTimeout(() => doMeasure(), 200); + }; + window.addEventListener('resize', onResize); + return () => { window.removeEventListener('resize', onResize); clearTimeout(timer); }; + }, [doMeasure]); + + useEffect(() => { + if (!autoFitReady) return; + const el = containerRef.current; + if (!el) return; + const recalc = () => { + const cm = contentMinRef.current; + let fixedSum = IDX_WIDTH; + for (const colId of Object.keys(DEF_SIZES)) { + if (colId === 'name' || !columnVisibility[colId]) continue; + fixedSum += colWidthsRef.current[colId] || (cm[colId] != null ? cm[colId] + MIN_PAD : DEF_SIZES[colId]); + } + const avail = el.clientWidth - fixedSum; + const nameMin = cm.name != null ? cm.name + MIN_PAD : DEF_SIZES.name; + colWidthsRef.current = { ...colWidthsRef.current, name: Math.max(nameMin, avail) }; + setTick(t => t + 1); + }; + recalc(); + const ro = new ResizeObserver(recalc); + ro.observe(el); + return () => ro.disconnect(); + }, [autoFitReady, columnVisibility]); + + useEffect(() => { + if (!resizing) return; + const onMove = (e) => { + const diff = e.clientX - resizing.startX; + const newWidth = Math.max(40, resizing.startWidth + diff); + colWidthsRef.current = { ...colWidthsRef.current, [resizing.colId]: newWidth }; + setResizing(prev => prev ? { ...prev, _tick: Date.now() } : null); + }; + const onUp = () => { persistWidths(); setResizing(null); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + return () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + }, [resizing, persistWidths]); + + const dlcEnabled = dlcMods.some(m => m.enabled); + const toggleDLC = () => { dlcMods.forEach(m => { if (dlcEnabled === m.enabled) toggleMod(m.name); }); }; + + const visibleCols = useMemo(() => Object.entries(columnVisibility).filter(([,v]) => v).map(([k]) => k), [columnVisibility]); + const vis = useCallback((key) => columnVisibility[key], [columnVisibility]); + + const columns = useMemo(() => [ + { id: 'compatibility', accessorKey: 'compatibility', header: t('Compatibility'), size: DEF_SIZES.compatibility, enableSorting: true, + cell: ({ row }) =>
    {row.original.compatibility ? : }
    , + }, + { id: 'enabled', accessorKey: 'enabled', header: t('Enabled'), size: DEF_SIZES.enabled, enableSorting: true, + cell: ({ row }) => { + const m = row.original; + const isDLC = DLC_MODS.has(m.name); + if (isDLC) return
    {m.enabled ? : }
    ; + return (
    {disabled ? (m.enabled ? : ) : m.enabled ? toggleMod(m.name)}/> : toggleMod(m.name)}/>}
    ); + }, + }, + { id: 'name', accessorKey: 'name', header: t('Name'), size: DEF_SIZES.name, enableSorting: true, + cell: ({ row }) => , + }, + { id: 'version', accessorKey: 'version', header: t('Mod Version'), size: DEF_SIZES.version, enableSorting: true, + cell: ({ row }) => {row.original.version}, + }, + { id: 'factorio', header: t('Game Ver'), size: DEF_SIZES.factorio, enableSorting: false, + cell: ({ row }) => { const v = row.original.factorio_version || ''; const parts = String(v).split('.'); while (parts.length > 2 && parts[parts.length-1] === '0') parts.pop(); return ≥ {parts.join('.')}; }, + }, + { id: 'size', accessorKey: 'size', header: t('File Size'), size: DEF_SIZES.size, enableSorting: true, + cell: ({ row }) => {row.original.file_size > 0 ? ((row.original.file_size/1024).toFixed(0)+' KB') : '-'}, + }, + { id: 'actions', header: t('Other Actions'), size: DEF_SIZES.actions, enableSorting: false, enableReorder: false, + cell: ({ row }) => { + const m = row.original; + if (disabled || DLC_MODS.has(m.name)) return null; + return (
    window.open('https://mods.factorio.com/mod/' + m.name, '_blank')}/> deleteMod(m.name)}/>
    ); + }, + }, + ], [t, factorioVersion, updateMod, toggleMod, deleteMod, addUpdatableMod, disabled, regularMods, visibleCols]); + + const table = useReactTable({ + data: sortedMods, + columns, + state: { sorting, columnVisibility, columnOrder }, + onSortingChange: setSorting, + onColumnVisibilityChange: updater => { + setColumnVisibility(prev => { + const next = typeof updater === 'function' ? updater(prev) : updater; + next.name = true; + localStorage.setItem('modlist_cols_v3', JSON.stringify(next)); + return next; + }); + }, + onColumnOrderChange: updater => { + setColumnOrder(prev => { + const next = typeof updater === 'function' ? updater(prev) : updater; + localStorage.setItem('modlist_colorder_v2', JSON.stringify(next)); + return next; + }); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + const onResizeStart = useCallback((e, colId) => { + e.preventDefault(); + colWidthsRef.current = { ...colWidthsRef.current }; + setResizing({ colId, startX: e.clientX, startWidth: colWidthsRef.current[colId] || DEF_SIZES[colId] || 100 }); + }, []); + + const onDragStart = useCallback((e, colId) => { + dragCol.current = colId; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', colId); + }, []); + + const onDragOver = useCallback((e, colId) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragCol.current !== colId) setDragOverCol(colId); + }, []); + + const onDragLeave = useCallback(() => { setDragOverCol(null); }, []); + + const onDrop = useCallback((e, targetId) => { + e.preventDefault(); + setDragOverCol(null); + const src = dragCol.current; + if (!src || src === targetId) return; + setColumnOrder(prev => { + const a = [...prev]; + const i1 = a.indexOf(src), i2 = a.indexOf(targetId); + if (i1 >= 0 && i2 >= 0) { a.splice(i1, 1); a.splice(i2, 0, src); } + return a; + }); + }, []); + + const getColWidth = useCallback((colId) => { + return colWidthsRef.current[colId] || DEF_SIZES[colId] || 100; + }, []); + + const toggleCol = useCallback((key) => { + if (key === 'name') return; + setColumnVisibility(prev => { + const next = { ...prev, [key]: !prev[key] }; + localStorage.setItem('modlist_cols_v3', JSON.stringify(next)); + return next; + }); + }, []); + + const visibleLeaves = table.getVisibleLeafColumns(); + const colIds = visibleLeaves.map(c => c.id); + + return ( +
    + + + {table.getHeaderGroups().map(hg => ( + + + {hg.headers.map(header => { + const c = header.column; + const dragging = dragOverCol === c.id; + return ( + + ); + })} + + ))} + + + {factorioVersion !== null && dlcMods.length > 0 && ( + + + {colIds.map(colId => { + if (colId === 'actions') return ; + if (colId === 'enabled') return ; + if (colId === 'name') return ; + if (colId === 'version') return ; + if (colId === 'factorio') return ; + if (colId === 'size') return ; + return + )} + {table.getRowModel().rows.map(row => ( + + + {row.getVisibleCells().map(cell => ( + + ))} + + ))} + +
    +
    + + {showSettings && ( +
    + {columns.filter(c => c.id !== 'actions' && c.id !== 'name').map(c => ( + + ))} +
    + )} +
    +
    onDragStart(e, c.id)} + onDragOver={(e) => onDragOver(e, c.id)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, c.id)}> +
    + {flexRender(c.columnDef.header, header.getContext())} + {c.getIsSorted() === 'asc' ? : + c.getIsSorted() === 'desc' ? : + c.getCanSort() ? : null} +
    +
    { e.stopPropagation(); onResizeStart(e, c.id); }}> +
    +
    +
    DLC; + if (colId === 'compatibility') return {disabled ? (dlcEnabled ? : ) : (dlcEnabled ? : )}{t('Space Age DLC')}{dlcMods[0]?.version}≥ {dlcMods[0]?.factorio_version}-; + })} +
    {row.index + 1} + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    +
    + ); +}; -export default ModList; \ No newline at end of file +export default ModList; diff --git a/ui/App/views/Mods/components/ModPack.jsx b/ui/App/views/Mods/components/ModPack.jsx index 1b8bca0a..254f8268 100644 --- a/ui/App/views/Mods/components/ModPack.jsx +++ b/ui/App/views/Mods/components/ModPack.jsx @@ -1,4 +1,5 @@ import React, {useState} from "react"; +import { useTranslation } from 'react-i18next'; import {faSpinner, faTrashAlt, faUpload} from "@fortawesome/free-solid-svg-icons"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import modsResource from "../../../../api/resources/mods"; @@ -7,6 +8,8 @@ import ConfirmDialog from "../../../components/ConfirmDialog"; const ModPack = ({modPack, reloadModPacks, factorioVersion, reloadMods, disabled = false}) => { + const { t } = useTranslation('mods'); + const [isLoading, setIsLoading] = useState(false); const [isLoadModPackDialogOpen, setIsLoadModPackDialogOpen] = useState(false); @@ -63,8 +66,8 @@ const ModPack = ({modPack, reloadModPacks, factorioVersion, reloadMods, disabled icon={isLoading ? faSpinner : faUpload} /> setIsLoadModPackDialogOpen(false)} onSuccess={() => loadModPack(modPack.name)} diff --git a/ui/App/views/Mods/components/UploadMod.jsx b/ui/App/views/Mods/components/UploadMod.jsx index f1083508..d942144f 100644 --- a/ui/App/views/Mods/components/UploadMod.jsx +++ b/ui/App/views/Mods/components/UploadMod.jsx @@ -1,4 +1,5 @@ import React, {useState} from "react"; +import { useTranslation } from 'react-i18next'; import Button from "../../../components/Button"; import Label from "../../../components/Label"; import {useForm} from "react-hook-form"; @@ -6,37 +7,68 @@ import modsResource from "../../../../api/resources/mods"; const UploadMod = ({refetchInstalledMods}) => { - const defaultFileName = 'Select File ...' - const [fileName, setFileName] = useState(defaultFileName); + const { t } = useTranslation('mods'); + const defaultFileText = t('selectModFile') + const [fileName, setFileName] = useState(defaultFileText); + const [uploadProgress, setUploadProgress] = useState({current: 0, total: 0}); const {register, handleSubmit} = useForm(); const [isUploading, setIsUploading] = useState(false); - const onSubmit = (data, e) => { - setIsUploading(true) - modsResource.upload(data.mod_file[0]) - .then(refetchInstalledMods) - .finally(() => { - e.target.reset() - setFileName(defaultFileName) - setIsUploading(false); - }) + const onSubmit = async (data, e) => { + const files = data.mod_file; + if (!files || files.length === 0) return; + + setIsUploading(true); + setUploadProgress({current: 0, total: files.length}); + + for (let i = 0; i < files.length; i++) { + try { + await modsResource.upload(files[i]); + } catch (err) {} + setUploadProgress({current: i + 1, total: files.length}); + } + + refetchInstalledMods(); + e.target.reset(); + setFileName(defaultFileText); + setUploadProgress({current: 0, total: 0}); + setIsUploading(false); } return (
    -