Compare commits

...

4 Commits

Author SHA1 Message Date
ld 29b176d2db build(web): add project-local Playwright tooling
- build: pin Playwright Test and MCP with repository-local npm, browser, and artifact paths plus sandboxed headless Chromium configuration.
- test: add a browser smoke test for the login page while keeping Playwright isolated from Vitest.
- docs: document Debian dependency setup, dual Chromium revisions, E2E commands, and project-scoped Codex MCP usage.
2026-07-15 20:17:24 +08:00
ld 04eb8727eb feat(web): add authenticated file workflow
- feat: connect login and logout to the REST API with session-scoped tokens, protected routing, single-flight refresh, and authenticated query cache cleanup.
- feat: add the Ant Design application shell with root file listing, pagination, small-file upload, and authenticated download.
- test: cover session storage and API retry and error behavior with Vitest.
- docs: document the browser milestone, same-origin API topology, and deferred transfer and account features.
2026-07-15 20:16:11 +08:00
ld 99e5758d4c test(server): cover authenticated file transfer flow
- test: exercise register, login, empty listing, small-file upload, updated listing, and authenticated download through the HTTP router.
- docs: mark the small-file integration flow as covered in the server roadmap.
2026-07-15 20:06:40 +08:00
ld 159bb27493 feat: establish the core API framework and scaffold the Web client (WIP)
- feat: implement the WIP API foundation for file management, administration, logging, error handling, and local storage.
- feat: scaffold the WIP React Web client with routing, data-query, styling, and build foundations.
- docs: reorganize server and Web documentation and add repository review guidance.
2026-07-14 17:55:58 +08:00
31 changed files with 2001 additions and 43 deletions
+22
View File
@@ -0,0 +1,22 @@
[mcp_servers.playwright]
command = "mise"
args = [
"exec",
"--",
"node",
"node_modules/@playwright/mcp/cli.js",
"--headless",
"--browser",
"chromium",
"--sandbox",
"--isolated",
"--output-dir",
"../.artifacts/playwright-mcp",
"--output-max-size",
"104857600",
]
cwd = "web"
enabled = true
required = false
startup_timeout_sec = 30
tool_timeout_sec = 120
+4
View File
@@ -34,6 +34,10 @@ go.work.sum
# Mise # Mise
mise.local.toml mise.local.toml
# Project-local development caches and browser artifacts
/.cache/
/.artifacts/
# MyGO configuration # MyGO configuration
# Tracking config.example.yaml in version control # Tracking config.example.yaml in version control
config.yaml config.yaml
+7 -4
View File
@@ -2,7 +2,8 @@
MyGO is a WebDisk (cloud drive) server, written in Go. MyGO is a WebDisk (cloud drive) server, written in Go.
**Current status**: pre-alpha — project skeleton with no core functionality implemented yet. **Current status**: pre-alpha — basic JWT authentication and file transfer flows
are available while their APIs and large-file behavior continue to evolve.
--- ---
@@ -11,8 +12,8 @@ MyGO is a WebDisk (cloud drive) server, written in Go.
### v0 ### v0
- [ ] CLI configuration management (`mygo config`) - [ ] CLI configuration management (`mygo config`)
- [ ] User authentication (JWT) - [x] User authentication (JWT)
- [ ] File upload / download / management (HTTP API) - [x] Basic file upload / download / management (HTTP API)
- [ ] Admin endpoints - [ ] Admin endpoints
- [ ] WebDAV support - [ ] WebDAV support
@@ -39,7 +40,9 @@ MyGO is a WebDisk (cloud drive) server, written in Go.
## Frontend ## Frontend
The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in `web/`. The frontend uses React + TypeScript and communicates with the backend via HTTP
API. Its first milestone supports login, a root file list, single-file upload,
and authenticated download. Frontend source is maintained in `web/`.
--- ---
+1 -1
View File
@@ -27,7 +27,7 @@ Package-level implementation order (each task includes unit tests):
10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place) 10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place)
11. `internal/server` — Gin router, route registration, graceful shutdown ✅ 11. `internal/server` — Gin router, route registration, graceful shutdown ✅
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done) 12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done)
13. Integration tests 13. Integration tests 🛠 (authenticated register → login → upload → list → download route flow covered with a small file)
14. Architecture boundary tests ✅ 14. Architecture boundary tests ✅
## Future ## Future
+52
View File
@@ -17,3 +17,55 @@
- The Web and future native clients consume the same client-neutral API contracts. - The Web and future native clients consume the same client-neutral API contracts.
- MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model. - MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model.
- MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic. - MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic.
## 2026-07-14: Browser Authentication and Root File Workflow
**Context**: The first Web milestone needs to exercise the existing login, file
list, upload, and download APIs without committing to the later directory,
account, admin, or large-transfer designs.
**Decisions**:
| Area | Choice | Guidance |
|------|--------|----------|
| API topology | Same-origin `/api/v1` | Vite proxies `/api` to the local Go server. Production uses a same-origin reverse proxy; the milestone does not add CORS or Go static hosting. |
| Browser session | Token pair in `sessionStorage` | Reloading the tab preserves the session, while closing the tab clears it. Do not add persistent login until the token transport design is revisited. |
| Token refresh | Refresh once after a protected request returns 401 | Share one in-flight refresh across concurrent failures, store the rotated pair, and retry each request once. Clear the session if refresh or the retry fails. |
| File scope | Root directory only | List root entries, upload one file to root, and download files. Show directories as non-interactive rows. |
| Transfer model | Browser `FormData` upload and authenticated Blob download | This is intentionally limited to the small-file milestone; progress, streaming-to-disk, chunking, resume, and queues remain deferred. |
**Consequences**:
- The API client owns bearer headers, error parsing, refresh coordination, and
session invalidation; pages consume operation-specific functions.
- TanStack Query caches are cleared whenever an authenticated session ends so
one user cannot see another user's cached file metadata.
- The file picker is independent of Ant Design upload request behavior, keeping
transfer policy in MyGO code.
- Handwritten TypeScript wire types remain temporary until an OpenAPI contract
is available.
## 2026-07-14: Project-local Headless Browser Tooling
**Context**: Browser behavior needs deterministic E2E coverage and interactive
agent debugging inside a long-lived headless Debian Incus container. Tooling
should remain reproducible without placing browser binaries or generated
artifacts in a developer's home directory.
**Decisions**:
| Area | Choice | Guidance |
|------|--------|----------|
| Regression tests | Playwright Test with bundled Chromium | Keep browser E2E tests separate from `npm run check` because browser installation is an explicit environment setup step. |
| Agent debugging | Official Playwright MCP over project-scoped STDIO | Codex starts the locked local package from `.codex/config.toml`; do not use a global install, an `@latest` npx invocation, or a listening MCP service. |
| Browser state | Headless, isolated, and sandboxed | Use bundled Chromium, discard the MCP profile after each session, and retain the Chromium sandbox supported by the Incus environment. |
| Local resources | Repository `.cache/` and `.artifacts/` directories | `mise.toml` defines the portable npm and browser cache paths. Generated resources remain ignored by Git. |
| Browser versions | Install both locked Playwright revisions | The stable test runner and current MCP package require different Chromium revisions; do not force either package onto an unsupported executable. |
**Consequences**:
- Debian browser libraries are installed once in the Incus container root
filesystem; npm packages, browsers, traces, screenshots, and MCP output stay
project-scoped.
- A fresh environment runs `npm ci`, the system dependency installer, and both
browser install scripts before browser tests or MCP debugging.
- Browser failures can retain traces, screenshots, and video under
`.artifacts/playwright/` without adding generated files to source control.
+19 -3
View File
@@ -8,9 +8,23 @@
- Production may serve `web/dist` from MyGO or a reverse proxy. - Production may serve `web/dist` from MyGO or a reverse proxy.
- Shared API contracts should remain client-neutral and eventually be described by OpenAPI. - Shared API contracts should remain client-neutral and eventually be described by OpenAPI.
## Current Milestone
The browser client now provides the first authenticated file workflow:
- Email/password login through the shared REST API
- Session-scoped access and refresh tokens with one automatic refresh retry
- Protected application shell and root file list
- Single-file upload to the root directory and authenticated download
- Server-side pagination with 50 items per page
Directory navigation and management, multi-file queues, transfer progress,
resumable or chunked transfers, account/profile/settings screens, and admin
screens remain deferred.
## Foundation ## Foundation
The initial project contains only the framework and styling foundation: The project foundation contains:
- Node.js 24 and npm - Node.js 24 and npm
- Vite - Vite
@@ -19,7 +33,11 @@ The initial project contains only the framework and styling foundation:
- TanStack Query provider - TanStack Query provider
- Tailwind CSS 4 through its Vite plugin - Tailwind CSS 4 through its Vite plugin
- Ant Design provider and components - Ant Design provider and components
- Ant Design icons for application and file actions
- Oxlint from the Vite template - Oxlint from the Vite template
- Vitest for framework-independent client and session tests
- Playwright Test with a headless Chromium smoke test
- Project-scoped Playwright MCP for interactive browser debugging
Ant Design owns reusable UI components and theme tokens. Tailwind CSS is initially limited to application layout, spacing, and responsive utilities. Ant Design owns reusable UI components and theme tokens. Tailwind CSS is initially limited to application layout, spacing, and responsive utilities.
@@ -40,10 +58,8 @@ web/src/
Some dependencies are suggested for future implementation. Refer these only when the corresponding feature is implemented and propose better choices if any: Some dependencies are suggested for future implementation. Refer these only when the corresponding feature is implemented and propose better choices if any:
- `@ant-design/icons`: Add Ant Design-consistent application and action icons when real screens require them.
- `openapi-typescript`: Generate TypeScript API types from the shared OpenAPI document. - `openapi-typescript`: Generate TypeScript API types from the shared OpenAPI document.
- `openapi-fetch`: Provide a small type-safe Fetch client based on generated OpenAPI types. - `openapi-fetch`: Provide a small type-safe Fetch client based on generated OpenAPI types.
- `openapi-react-query`: Connect generated OpenAPI operations to TanStack Query if handwritten query adapters become repetitive. - `openapi-react-query`: Connect generated OpenAPI operations to TanStack Query if handwritten query adapters become repetitive.
- `zustand`: Manage a cross-route upload queue, bulk selection, or other complex client-only state if React state is insufficient. - `zustand`: Manage a cross-route upload queue, bulk selection, or other complex client-only state if React state is insufficient.
- `vitest`: Run unit and integration tests using the Vite toolchain.
- `pdfjs-dist`: Preview PDF files in the browser when document preview is implemented. - `pdfjs-dist`: Preview PDF files in the browser when document preview is implemented.
+192
View File
@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"io"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -14,6 +16,7 @@ import (
"github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/config"
"github.com/dhao2001/mygo/internal/repository" "github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/service" "github.com/dhao2001/mygo/internal/service"
"github.com/dhao2001/mygo/internal/storage"
) )
func TestVersionRoute(t *testing.T) { func TestVersionRoute(t *testing.T) {
@@ -181,3 +184,192 @@ func TestAdminRoutes(t *testing.T) {
t.Errorf("no-auth delete: status=%d, want %d, body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) t.Errorf("no-auth delete: status=%d, want %d, body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
} }
} }
func TestAuthenticatedFileFlow(t *testing.T) {
dbCfg := config.DatabaseConfig{
Driver: "sqlite3",
SQLite: config.SQLiteConfig{Path: ":memory:"},
}
db, err := repository.Open(dbCfg)
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := repository.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
userRepo := repository.NewUserRepository(db)
sessionRepo := repository.NewSessionRepository(db)
fileRepo := repository.NewFileRepository(db)
credentialRepo := repository.NewCredentialRepository(db)
store, err := storage.NewLocalStorage(t.TempDir())
if err != nil {
t.Fatalf("create local storage: %v", err)
}
const maxUploadSize = 20 << 20
jwtSecret := []byte("test-secret")
authService := service.NewAuthService(
userRepo,
sessionRepo,
credentialRepo,
jwtSecret,
15*time.Minute,
168*time.Hour,
)
fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil)
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: string(jwtSecret),
AccessTTL: 15 * time.Minute,
RefreshTTL: 168 * time.Hour,
},
Storage: config.StorageConfig{MaxUploadSize: maxUploadSize},
}
webApp := app.NewWebApp(
cfg,
db,
userRepo,
sessionRepo,
fileRepo,
credentialRepo,
authService,
service.NewAdminService(userRepo),
fileService,
store,
)
t.Cleanup(func() {
if err := webApp.Close(); err != nil {
t.Errorf("close web app: %v", err)
}
})
router := NewRouter(webApp)
registerBody := []byte(`{"username":"web-user","email":"web@example.com","password":"password123"}`)
registerRequest := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/register",
bytes.NewReader(registerBody),
)
registerRequest.Header.Set("Content-Type", "application/json")
registerResponse := httptest.NewRecorder()
router.ServeHTTP(registerResponse, registerRequest)
if registerResponse.Code != http.StatusCreated {
t.Fatalf("register: status=%d, body=%s", registerResponse.Code, registerResponse.Body.String())
}
loginBody := []byte(`{"email":"web@example.com","password":"password123"}`)
loginRequest := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/login",
bytes.NewReader(loginBody),
)
loginRequest.Header.Set("Content-Type", "application/json")
loginResponse := httptest.NewRecorder()
router.ServeHTTP(loginResponse, loginRequest)
if loginResponse.Code != http.StatusOK {
t.Fatalf("login: status=%d, body=%s", loginResponse.Code, loginResponse.Body.String())
}
var tokens struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(loginResponse.Body.Bytes(), &tokens); err != nil {
t.Fatalf("decode login response: %v", err)
}
if tokens.AccessToken == "" {
t.Fatal("login response has empty access token")
}
listRequest := httptest.NewRequest(http.MethodGet, "/api/v1/files?offset=0&limit=50", nil)
listRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
listResponse := httptest.NewRecorder()
router.ServeHTTP(listResponse, listRequest)
if listResponse.Code != http.StatusOK {
t.Fatalf("initial list: status=%d, body=%s", listResponse.Code, listResponse.Body.String())
}
var initialList struct {
Total int64 `json:"total"`
}
if err := json.Unmarshal(listResponse.Body.Bytes(), &initialList); err != nil {
t.Fatalf("decode initial list: %v", err)
}
if initialList.Total != 0 {
t.Fatalf("initial total=%d, want 0", initialList.Total)
}
fileContent := []byte("MyGO browser milestone upload")
uploadBody := &bytes.Buffer{}
multipartWriter := multipart.NewWriter(uploadBody)
filePart, err := multipartWriter.CreateFormFile("file", "milestone.txt")
if err != nil {
t.Fatalf("create multipart file: %v", err)
}
if _, err := filePart.Write(fileContent); err != nil {
t.Fatalf("write multipart file: %v", err)
}
if err := multipartWriter.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
uploadRequest := httptest.NewRequest(http.MethodPost, "/api/v1/files", uploadBody)
uploadRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
uploadRequest.Header.Set("Content-Type", multipartWriter.FormDataContentType())
uploadResponse := httptest.NewRecorder()
router.ServeHTTP(uploadResponse, uploadRequest)
if uploadResponse.Code != http.StatusCreated {
t.Fatalf("upload: status=%d, body=%s", uploadResponse.Code, uploadResponse.Body.String())
}
var uploadedFile struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
}
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &uploadedFile); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if uploadedFile.Name != "milestone.txt" || uploadedFile.Size != int64(len(fileContent)) {
t.Fatalf("uploaded file=%+v, want name milestone.txt and size %d", uploadedFile, len(fileContent))
}
listRequest = httptest.NewRequest(http.MethodGet, "/api/v1/files?offset=0&limit=50", nil)
listRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
listResponse = httptest.NewRecorder()
router.ServeHTTP(listResponse, listRequest)
if listResponse.Code != http.StatusOK {
t.Fatalf("list after upload: status=%d, body=%s", listResponse.Code, listResponse.Body.String())
}
var uploadedList struct {
Files []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"files"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(listResponse.Body.Bytes(), &uploadedList); err != nil {
t.Fatalf("decode uploaded list: %v", err)
}
if uploadedList.Total != 1 || len(uploadedList.Files) != 1 || uploadedList.Files[0].ID != uploadedFile.ID {
t.Fatalf("uploaded list=%+v, want the uploaded file", uploadedList)
}
downloadRequest := httptest.NewRequest(
http.MethodGet,
"/api/v1/files/"+uploadedFile.ID+"/content",
nil,
)
downloadRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
downloadResponse := httptest.NewRecorder()
router.ServeHTTP(downloadResponse, downloadRequest)
if downloadResponse.Code != http.StatusOK {
t.Fatalf("download: status=%d, body=%s", downloadResponse.Code, downloadResponse.Body.String())
}
downloadedContent, err := io.ReadAll(downloadResponse.Body)
if err != nil {
t.Fatalf("read download response: %v", err)
}
if !bytes.Equal(downloadedContent, fileContent) {
t.Fatalf("downloaded content=%q, want %q", downloadedContent, fileContent)
}
if disposition := downloadResponse.Header().Get("Content-Disposition"); disposition == "" {
t.Fatal("download response has empty Content-Disposition")
}
}
+4
View File
@@ -1,3 +1,7 @@
[tools] [tools]
go = "1.26.2" go = "1.26.2"
node = "24" node = "24"
[env]
NPM_CONFIG_CACHE = "{{config_root}}/.cache/npm"
PLAYWRIGHT_BROWSERS_PATH = "{{config_root}}/.cache/ms-playwright"
+69 -4
View File
@@ -4,15 +4,80 @@ MyGO's browser client is a pure client-side rendered application built with Reac
## Development ## Development
Install the repository-pinned Go and Node.js versions from the repository
root:
```bash ```bash
npm install mise install
npm run dev
``` ```
Start the Go API from the repository root:
```bash
mise exec -- go run . serve
```
Create a development account through the existing public API if needed:
```bash
curl --request POST http://127.0.0.1:10086/api/v1/auth/register \
--header 'Content-Type: application/json' \
--data '{"username":"web-user","email":"web@example.com","password":"password123"}'
```
Then start the browser client from `web/`:
```bash
mise exec -- npm ci
mise exec -- npm run dev
```
Vite proxies `/api` to `http://127.0.0.1:10086`. Production deployments must
serve the static build and `/api/v1` from the same origin, normally through a
reverse proxy.
## Browser Testing and Debugging
The repository uses Playwright Test for repeatable browser tests and the
official Playwright MCP server for interactive browser debugging. Mise keeps
the npm cache and Playwright browser binaries under the repository-level
`.cache/` directory.
Install the Debian browser dependencies once per development container:
```bash
mise exec -- npm run playwright:install:deps
```
Install the Chromium revisions used by Playwright Test and Playwright MCP:
```bash
mise exec -- npm run playwright:install
mise exec -- npm run playwright:install:mcp
```
The two commands are intentionally separate because the locked stable test
runner and MCP package currently depend on different Playwright revisions.
Both revisions are stored in `.cache/ms-playwright/` and are ignored by Git.
Run the headless browser tests with Chromium sandboxing enabled:
```bash
mise exec -- npm run test:e2e
```
Codex loads the project-scoped Playwright MCP server from
`.codex/config.toml` after the repository is trusted and Codex is restarted.
The MCP browser uses bundled Chromium in headless, isolated, sandboxed mode;
its generated files are kept under `.artifacts/playwright-mcp/`.
## Checks ## Checks
```bash ```bash
npm run check mise exec -- npm run check
``` ```
The production build is emitted to `dist/` as static assets. See `docs/web/roadmap.md` at the repository root for architecture boundaries and planned dependencies. The production build is emitted to `dist/` as static assets. Browser E2E tests
are run separately because they require the browser setup above. See
`docs/web/roadmap.md` at the repository root for architecture boundaries and
planned dependencies.
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test'
test('renders the login page in a real browser', async ({ page }) => {
const response = await page.goto('/login')
expect(response?.ok()).toBe(true)
await expect(page.getByRole('heading', { name: 'Sign in to MyGO' })).toBeVisible()
await expect(page.getByLabel('Email')).toBeVisible()
await expect(page.getByLabel('Password')).toBeVisible()
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible()
})
+507 -1
View File
@@ -8,6 +8,7 @@
"name": "mygo-web", "name": "mygo-web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"antd": "^6.5.1", "antd": "^6.5.1",
"react": "^19.2.7", "react": "^19.2.7",
@@ -15,6 +16,8 @@
"react-router": "^8.2.0" "react-router": "^8.2.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/mcp": "0.0.78",
"@playwright/test": "1.61.1",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
@@ -23,7 +26,8 @@
"oxlint": "^1.71.0", "oxlint": "^1.71.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1" "vite": "^8.1.1",
"vitest": "^4.1.10"
}, },
"engines": { "engines": {
"node": ">=24 <25" "node": ">=24 <25"
@@ -579,6 +583,86 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@playwright/mcp": {
"version": "0.0.78",
"resolved": "https://registry.npmmirror.com/@playwright/mcp/-/mcp-0.0.78.tgz",
"integrity": "sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0-alpha-1783623505000",
"playwright-core": "1.62.0-alpha-1783623505000"
},
"bin": {
"playwright-mcp": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/test/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rc-component/async-validator": { "node_modules/@rc-component/async-validator": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz", "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz",
@@ -1546,6 +1630,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz",
@@ -1855,6 +1946,31 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
@@ -1911,6 +2027,119 @@
} }
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/antd": { "node_modules/antd": {
"version": "6.5.1", "version": "6.5.1",
"resolved": "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz", "resolved": "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz",
@@ -1974,6 +2203,26 @@
"react-dom": ">=18.0.0" "react-dom": ">=18.0.0"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
@@ -1989,6 +2238,13 @@
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie-es": { "node_modules/cookie-es": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz",
@@ -2031,6 +2287,33 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -2386,6 +2669,20 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/oxlint": { "node_modules/oxlint": {
"version": "1.74.0", "version": "1.74.0",
"resolved": "https://registry.npmmirror.com/oxlint/-/oxlint-1.74.0.tgz", "resolved": "https://registry.npmmirror.com/oxlint/-/oxlint-1.74.0.tgz",
@@ -2435,6 +2732,13 @@
} }
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2455,6 +2759,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.62.0-alpha-1783623505000",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.0-alpha-1783623505000.tgz",
"integrity": "sha512-6KV9h4PP3hqu4NaGdxxcijWfYh9LJcFI/R2sP4TTC4I5cFo3oRawN0ETlW5MkE3cQEgKhhoj0KUNz4sfpCT0Tg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0-alpha-1783623505000"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0-alpha-1783623505000",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.0-alpha-1783623505000.tgz",
"integrity": "sha512-CPJZdsA/KGT2QQlekiV6Wt+QlQrZHVSZ6oiNtOI/bYYOIVLM8jfKGWTM4zQiyd4UN+40Cq4cA6lxmZHZbtPvJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.19", "version": "8.5.19",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz",
@@ -2581,6 +2932,13 @@
"compute-scroll-into-view": "^3.0.2" "compute-scroll-into-view": "^3.0.2"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2591,6 +2949,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/string-convert": { "node_modules/string-convert": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
@@ -2633,6 +3005,23 @@
"node": ">=12.22" "node": ">=12.22"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2650,6 +3039,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
@@ -2756,6 +3155,113 @@
"optional": true "optional": true
} }
} }
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
} }
} }
} }
+11 -2
View File
@@ -11,11 +11,17 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"playwright:install": "playwright install chromium",
"playwright:install:deps": "playwright install-deps chromium",
"playwright:install:mcp": "node node_modules/playwright/cli.js install chromium",
"test": "vitest run",
"test:e2e": "playwright test",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"check": "npm run lint && npm run build", "check": "npm run lint && npm run test && npm run build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"antd": "^6.5.1", "antd": "^6.5.1",
"react": "^19.2.7", "react": "^19.2.7",
@@ -23,6 +29,8 @@
"react-router": "^8.2.0" "react-router": "^8.2.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/mcp": "0.0.78",
"@playwright/test": "1.61.1",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
@@ -31,6 +39,7 @@
"oxlint": "^1.71.0", "oxlint": "^1.71.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1" "vite": "^8.1.1",
"vitest": "^4.1.10"
} }
} }
+44
View File
@@ -0,0 +1,44 @@
import { defineConfig, devices } from '@playwright/test'
import { fileURLToPath } from 'node:url'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
export default defineConfig({
testDir: './e2e',
outputDir: `${repositoryRoot}/.artifacts/playwright/test-results`,
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['list'],
[
'html',
{
open: 'never',
outputFolder: `${repositoryRoot}/.artifacts/playwright/report`,
},
],
],
use: {
baseURL: 'http://127.0.0.1:5173',
launchOptions: {
chromiumSandbox: true,
},
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: 'http://127.0.0.1:5173/login',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+19
View File
@@ -0,0 +1,19 @@
import { apiClient } from './client.ts'
import type { TokenPair } from './types.ts'
export function login(email: string, password: string) {
return apiClient.request<TokenPair>('/auth/login', {
auth: false,
method: 'POST',
json: { email, password },
})
}
export function logout(refreshToken: string) {
return apiClient.request<void>('/auth/logout', {
auth: false,
method: 'POST',
json: { refresh_token: refreshToken },
responseType: 'void',
})
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import { createApiClient } from './client.ts'
import { createSessionStore } from './session.ts'
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('API client', () => {
it('adds the current access token to authenticated requests', async () => {
const store = createSessionStore()
store.set({ access_token: 'access-one', refresh_token: 'refresh-one' })
const fetchImplementation = vi.fn<typeof fetch>()
fetchImplementation.mockResolvedValue(jsonResponse({ ok: true }))
const client = createApiClient({
apiBase: '/test-api',
fetchImplementation,
store,
})
await expect(client.request('/files')).resolves.toEqual({ ok: true })
const [, options] = fetchImplementation.mock.calls[0]
expect(new Headers(options?.headers).get('Authorization')).toBe(
'Bearer access-one',
)
})
it('shares one refresh across concurrent 401 responses and retries both requests', async () => {
const store = createSessionStore()
store.set({ access_token: 'expired-access', refresh_token: 'refresh-one' })
let resolveRefresh: ((response: Response) => void) | undefined
const refreshResponse = new Promise<Response>((resolve) => {
resolveRefresh = resolve
})
const fetchImplementation = vi.fn<typeof fetch>(async (input, options) => {
const url = String(input)
if (url.endsWith('/auth/refresh')) {
return refreshResponse
}
const authorization = new Headers(options?.headers).get('Authorization')
if (authorization === 'Bearer expired-access') {
return jsonResponse({ error: { message: 'expired' } }, 401)
}
return jsonResponse({ url })
})
const client = createApiClient({ fetchImplementation, store })
const requests = Promise.all([
client.request('/files?offset=0'),
client.request('/files?offset=50'),
])
await vi.waitFor(() => {
expect(
fetchImplementation.mock.calls.filter(([input]) =>
String(input).endsWith('/auth/refresh'),
),
).toHaveLength(1)
})
resolveRefresh?.(
jsonResponse({
access_token: 'access-two',
refresh_token: 'refresh-two',
}),
)
await expect(requests).resolves.toHaveLength(2)
expect(store.get()).toEqual({
access_token: 'access-two',
refresh_token: 'refresh-two',
})
expect(fetchImplementation).toHaveBeenCalledTimes(5)
})
it('clears the session when refresh fails', async () => {
const store = createSessionStore()
store.set({ access_token: 'expired-access', refresh_token: 'expired-refresh' })
const fetchImplementation = vi.fn<typeof fetch>()
fetchImplementation
.mockResolvedValueOnce(jsonResponse({ error: { message: 'expired' } }, 401))
.mockResolvedValueOnce(
jsonResponse(
{ error: { message: 'invalid token', log_id: 'log-123' } },
401,
),
)
const client = createApiClient({ fetchImplementation, store })
const request = client.request('/files')
await expect(request).rejects.toMatchObject({
status: 401,
message: 'invalid token',
logId: 'log-123',
})
expect(store.get()).toBeNull()
})
it('clears the session when the retried request is still unauthorized', async () => {
const store = createSessionStore()
store.set({ access_token: 'expired-access', refresh_token: 'refresh-one' })
const fetchImplementation = vi.fn<typeof fetch>()
fetchImplementation
.mockResolvedValueOnce(jsonResponse({ error: { message: 'expired' } }, 401))
.mockResolvedValueOnce(
jsonResponse({
access_token: 'access-two',
refresh_token: 'refresh-two',
}),
)
.mockResolvedValueOnce(jsonResponse({ error: { message: 'denied' } }, 401))
const client = createApiClient({ fetchImplementation, store })
await expect(client.request('/files')).rejects.toMatchObject({
status: 401,
message: 'denied',
})
expect(store.get()).toBeNull()
})
})
+159
View File
@@ -0,0 +1,159 @@
import { sessionStore, type SessionStore } from './session.ts'
import type { ErrorResponse, TokenPair } from './types.ts'
const defaultApiBase = '/api/v1'
type ResponseType = 'blob' | 'json' | 'void'
export interface ApiRequestOptions {
auth?: boolean
body?: BodyInit | null
headers?: HeadersInit
json?: unknown
method?: string
responseType?: ResponseType
signal?: AbortSignal
}
interface ApiClientOptions {
apiBase?: string
fetchImplementation?: typeof fetch
store?: SessionStore
}
export class ApiError extends Error {
readonly status: number
readonly logId?: string
constructor(status: number, message: string, logId?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.logId = logId
}
}
async function responseError(response: Response): Promise<ApiError> {
let body: ErrorResponse | undefined
try {
body = (await response.json()) as ErrorResponse
} catch {
// Non-JSON failures use a stable fallback message.
}
const message =
body?.error?.message ?? `Request failed with status ${response.status}`
return new ApiError(response.status, message, body?.error?.log_id)
}
async function readResponse<T>(
response: Response,
responseType: ResponseType,
): Promise<T> {
if (!response.ok) {
throw await responseError(response)
}
if (responseType === 'blob') {
return (await response.blob()) as T
}
if (responseType === 'void' || response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export function createApiClient({
apiBase = defaultApiBase,
fetchImplementation = fetch,
store = sessionStore,
}: ApiClientOptions = {}) {
let refreshPromise: Promise<TokenPair> | null = null
const refreshSession = (): Promise<TokenPair> => {
if (refreshPromise) {
return refreshPromise
}
const refreshToken = store.get()?.refresh_token
if (!refreshToken) {
store.clear()
return Promise.reject(new ApiError(401, 'Your session has expired'))
}
refreshPromise = fetchImplementation(`${apiBase}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
.then((response) => readResponse<TokenPair>(response, 'json'))
.then((session) => {
store.set(session)
return session
})
.catch((error: unknown) => {
store.clear()
throw error
})
.finally(() => {
refreshPromise = null
})
return refreshPromise
}
const request = async <T>(
path: string,
options: ApiRequestOptions = {},
): Promise<T> => {
const {
auth = true,
body,
headers: providedHeaders,
json,
method = 'GET',
responseType = 'json',
signal,
} = options
const run = (accessToken?: string) => {
const headers = new Headers(providedHeaders)
let requestBody = body
if (json !== undefined) {
headers.set('Content-Type', 'application/json')
requestBody = JSON.stringify(json)
}
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetchImplementation(`${apiBase}${path}`, {
method,
headers,
body: requestBody,
signal,
})
}
const accessToken = auth ? store.get()?.access_token : undefined
if (auth && !accessToken) {
throw new ApiError(401, 'Authentication required')
}
let response = await run(accessToken)
if (auth && response.status === 401) {
const refreshedSession = await refreshSession()
response = await run(refreshedSession.access_token)
if (response.status === 401) {
store.clear()
}
}
return readResponse<T>(response, responseType)
}
return { request }
}
export const apiClient = createApiClient()
+27
View File
@@ -0,0 +1,27 @@
import { apiClient } from './client.ts'
import type { FileInfo, FileListResponse } from './types.ts'
export function listRootFiles(offset: number, limit: number, signal?: AbortSignal) {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
})
return apiClient.request<FileListResponse>(`/files?${params}`, { signal })
}
export function uploadRootFile(file: File) {
const body = new FormData()
body.append('file', file)
return apiClient.request<FileInfo>('/files', {
method: 'POST',
body,
})
}
export function downloadFile(fileID: string) {
return apiClient.request<Blob>(
`/files/${encodeURIComponent(fileID)}/content`,
{ responseType: 'blob' },
)
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import { createSessionStore } from './session.ts'
import type { TokenPair } from './types.ts'
class MemoryStorage {
private readonly values = new Map<string, string>()
getItem(key: string) {
return this.values.get(key) ?? null
}
removeItem(key: string) {
this.values.delete(key)
}
setItem(key: string, value: string) {
this.values.set(key, value)
}
}
const session: TokenPair = {
access_token: 'access-token',
refresh_token: 'refresh-token',
}
describe('session store', () => {
it('persists and reloads a valid token pair', () => {
const storage = new MemoryStorage()
const store = createSessionStore(storage)
store.set(session)
expect(store.get()).toEqual(session)
expect(createSessionStore(storage).get()).toEqual(session)
})
it('notifies subscribers when the session changes', () => {
const store = createSessionStore(new MemoryStorage())
const listener = vi.fn()
const unsubscribe = store.subscribe(listener)
store.set(session)
store.clear()
unsubscribe()
store.set(session)
expect(listener).toHaveBeenCalledTimes(2)
})
it('discards malformed browser state', () => {
const storage = new MemoryStorage()
storage.setItem('mygo.auth.session', '{"access_token":true}')
const store = createSessionStore(storage)
expect(store.get()).toBeNull()
expect(storage.getItem('mygo.auth.session')).toBeNull()
})
})
+87
View File
@@ -0,0 +1,87 @@
import type { TokenPair } from './types.ts'
const sessionStorageKey = 'mygo.auth.session'
type SessionListener = () => void
type SessionStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>
export interface SessionStore {
clear: () => void
get: () => TokenPair | null
set: (session: TokenPair) => void
subscribe: (listener: SessionListener) => () => void
}
function isTokenPair(value: unknown): value is TokenPair {
if (typeof value !== 'object' || value === null) {
return false
}
const candidate = value as Partial<TokenPair>
return (
typeof candidate.access_token === 'string' &&
candidate.access_token.length > 0 &&
typeof candidate.refresh_token === 'string' &&
candidate.refresh_token.length > 0
)
}
function readStoredSession(storage?: SessionStorage): TokenPair | null {
if (!storage) {
return null
}
const raw = storage.getItem(sessionStorageKey)
if (!raw) {
return null
}
try {
const parsed: unknown = JSON.parse(raw)
if (isTokenPair(parsed)) {
return parsed
}
} catch {
// Invalid browser state is treated as a signed-out session.
}
storage.removeItem(sessionStorageKey)
return null
}
export function createSessionStore(storage?: SessionStorage): SessionStore {
let currentSession = readStoredSession(storage)
const listeners = new Set<SessionListener>()
const notify = () => {
for (const listener of listeners) {
listener()
}
}
return {
get: () => currentSession,
set: (session) => {
currentSession = session
storage?.setItem(sessionStorageKey, JSON.stringify(session))
notify()
},
clear: () => {
if (currentSession === null && !storage?.getItem(sessionStorageKey)) {
return
}
currentSession = null
storage?.removeItem(sessionStorageKey)
notify()
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
const browserStorage =
typeof window === 'undefined' ? undefined : window.sessionStorage
export const sessionStore = createSessionStore(browserStorage)
+28
View File
@@ -0,0 +1,28 @@
export interface TokenPair {
access_token: string
refresh_token: string
}
export interface ErrorResponse {
error: {
message: string
log_id?: string
}
}
export interface FileInfo {
id: string
user_id: string
parent_id: string | null
name: string
size: number
mime_type: string
is_dir: boolean
created_at: string
updated_at: string
}
export interface FileListResponse {
files: FileInfo[]
total: number
}
+15
View File
@@ -0,0 +1,15 @@
import { Navigate, Outlet, useLocation } from 'react-router'
import { useAuthSession } from '../features/auth/useAuthSession.ts'
export function RequireAuth() {
const session = useAuthSession()
const location = useLocation()
if (!session) {
const from = `${location.pathname}${location.search}${location.hash}`
return <Navigate to="/login" replace state={{ from }} />
}
return <Outlet />
}
+51 -5
View File
@@ -1,14 +1,60 @@
import type { PropsWithChildren } from 'react' import {
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' QueryClient,
QueryClientProvider,
useQueryClient,
} from '@tanstack/react-query'
import { App as AntDesignApp, ConfigProvider } from 'antd' import { App as AntDesignApp, ConfigProvider } from 'antd'
import { useEffect, useRef, type PropsWithChildren } from 'react'
const queryClient = new QueryClient() import { ApiError } from '../api/client.ts'
import { useAuthSession } from '../features/auth/useAuthSession.ts'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) {
return false
}
return failureCount < 2
},
},
mutations: {
retry: false,
},
},
})
function AuthCacheBoundary({ children }: PropsWithChildren) {
const session = useAuthSession()
const activeQueryClient = useQueryClient()
const previousSession = useRef(session)
useEffect(() => {
if (previousSession.current && !session) {
activeQueryClient.clear()
}
previousSession.current = session
}, [activeQueryClient, session])
return children
}
export function AppProviders({ children }: PropsWithChildren) { export function AppProviders({ children }: PropsWithChildren) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfigProvider> <ConfigProvider
<AntDesignApp>{children}</AntDesignApp> theme={{
token: {
borderRadius: 8,
colorBgLayout: '#f5f7fb',
colorPrimary: '#2563eb',
},
}}
>
<AntDesignApp>
<AuthCacheBoundary>{children}</AuthCacheBoundary>
</AntDesignApp>
</ConfigProvider> </ConfigProvider>
</QueryClientProvider> </QueryClientProvider>
) )
+19 -2
View File
@@ -1,12 +1,29 @@
import { createBrowserRouter } from 'react-router' import { createBrowserRouter } from 'react-router'
import { HomePage } from '../pages/HomePage.tsx' import { AppShell } from '../components/AppShell.tsx'
import { FilesPage } from '../pages/FilesPage.tsx'
import { LoginPage } from '../pages/LoginPage.tsx'
import { NotFoundPage } from '../pages/NotFoundPage.tsx' import { NotFoundPage } from '../pages/NotFoundPage.tsx'
import { RequireAuth } from './RequireAuth.tsx'
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
element: <RequireAuth />,
children: [
{
element: <AppShell />,
children: [
{ {
path: '/', path: '/',
element: <HomePage />, element: <FilesPage />,
},
],
},
],
}, },
{ {
path: '*', path: '*',
+86
View File
@@ -0,0 +1,86 @@
import {
CloudOutlined,
FolderOpenOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import { useQueryClient } from '@tanstack/react-query'
import { App, Button, Layout, Menu, Typography } from 'antd'
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router'
import { logout } from '../api/auth.ts'
import { ApiError } from '../api/client.ts'
import { sessionStore } from '../api/session.ts'
const { Content, Header } = Layout
export function AppShell() {
const { message } = App.useApp()
const location = useLocation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [isLoggingOut, setIsLoggingOut] = useState(false)
const handleLogout = async () => {
const refreshToken = sessionStore.get()?.refresh_token
setIsLoggingOut(true)
try {
if (refreshToken) {
await logout(refreshToken)
}
} catch (error) {
const detail = error instanceof ApiError ? error.message : 'Server unavailable'
message.warning(`Signed out locally. ${detail}`)
} finally {
sessionStore.clear()
queryClient.clear()
navigate('/login', { replace: true })
setIsLoggingOut(false)
}
}
return (
<Layout className="min-h-screen">
<Header className="flex items-center gap-4 px-4 sm:px-6">
<button
type="button"
className="flex shrink-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-white"
onClick={() => navigate('/')}
>
<CloudOutlined className="text-xl" />
<Typography.Text className="hidden text-base font-semibold text-white sm:inline">
MyGO
</Typography.Text>
</button>
<Menu
className="min-w-0 flex-1"
theme="dark"
mode="horizontal"
selectedKeys={location.pathname === '/' ? ['files'] : []}
items={[
{
key: 'files',
icon: <FolderOpenOutlined />,
label: 'Files',
onClick: () => navigate('/'),
},
]}
/>
<Button
ghost
icon={<LogoutOutlined />}
loading={isLoggingOut}
onClick={handleLogout}
>
<span className="hidden sm:inline">Log out</span>
</Button>
</Header>
<Content className="p-4 sm:p-6">
<div className="mx-auto w-full max-w-7xl">
<Outlet />
</div>
</Content>
</Layout>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { useSyncExternalStore } from 'react'
import { sessionStore } from '../../api/session.ts'
export function useAuthSession() {
return useSyncExternalStore(
sessionStore.subscribe,
sessionStore.get,
sessionStore.get,
)
}
+2 -1
View File
@@ -5,9 +5,10 @@ html,
body, body,
#root { #root {
min-width: 320px; min-width: 320px;
min-height: 100%; min-height: 100vh;
} }
body { body {
margin: 0; margin: 0;
background: #f5f7fb;
} }
+241
View File
@@ -0,0 +1,241 @@
import {
DownloadOutlined,
FileOutlined,
FolderFilled,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Empty,
Space,
Table,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { useRef, useState, type ChangeEvent } from 'react'
import { ApiError } from '../api/client.ts'
import { downloadFile, listRootFiles, uploadRootFile } from '../api/files.ts'
import type { FileInfo } from '../api/types.ts'
const pageSize = 50
function formatBytes(bytes: number) {
if (bytes === 0) {
return '0 B'
}
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
const unitIndex = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
)
const value = bytes / 1024 ** unitIndex
return `${value.toFixed(unitIndex === 0 || value >= 10 ? 0 : 1)} ${units[unitIndex]}`
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
function errorMessage(error: unknown) {
return error instanceof ApiError ? error.message : 'Unable to reach the server'
}
export function FilesPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const [page, setPage] = useState(1)
const [downloadingID, setDownloadingID] = useState<string | null>(null)
const filesQuery = useQuery({
queryKey: ['files', 'root', page],
queryFn: ({ signal }) => listRootFiles((page - 1) * pageSize, pageSize, signal),
placeholderData: keepPreviousData,
})
const uploadMutation = useMutation({
mutationFn: uploadRootFile,
onSuccess: async (file) => {
message.success(`${file.name} uploaded`)
setPage(1)
await queryClient.invalidateQueries({ queryKey: ['files', 'root'] })
},
onError: (error) => message.error(errorMessage(error)),
})
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file && !uploadMutation.isPending) {
uploadMutation.mutate(file)
}
}
const handleDownload = async (file: FileInfo) => {
setDownloadingID(file.id)
try {
const blob = await downloadFile(file.id)
const objectURL = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectURL
anchor.download = file.name
document.body.append(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(objectURL), 0)
} catch (error) {
message.error(errorMessage(error))
} finally {
setDownloadingID(null)
}
}
const columns: TableColumnsType<FileInfo> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (name: string, file) => (
<Space>
{file.is_dir ? (
<FolderFilled className="text-amber-500" />
) : (
<FileOutlined className="text-blue-600" />
)}
<Typography.Text ellipsis={{ tooltip: name }}>{name}</Typography.Text>
</Space>
),
},
{
title: 'Type',
key: 'type',
width: 220,
responsive: ['md'],
render: (_, file) =>
file.is_dir ? <Tag color="gold">Folder</Tag> : file.mime_type || 'File',
},
{
title: 'Size',
dataIndex: 'size',
key: 'size',
width: 120,
responsive: ['sm'],
render: (size: number, file) => (file.is_dir ? '—' : formatBytes(size)),
},
{
title: 'Modified',
dataIndex: 'updated_at',
key: 'updated_at',
width: 190,
responsive: ['lg'],
render: formatDate,
},
{
title: 'Action',
key: 'action',
align: 'right',
width: 120,
render: (_, file) =>
file.is_dir ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Button
type="link"
icon={<DownloadOutlined />}
loading={downloadingID === file.id}
onClick={() => handleDownload(file)}
>
Download
</Button>
),
},
]
if (filesQuery.isError && !filesQuery.data) {
return (
<Alert
showIcon
type="error"
message="Unable to load files"
description={errorMessage(filesQuery.error)}
action={
<Button icon={<ReloadOutlined />} onClick={() => filesQuery.refetch()}>
Retry
</Button>
}
/>
)
}
return (
<Card>
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
<div>
<Typography.Title level={2} className="mb-1!">
Files
</Typography.Title>
<Typography.Text type="secondary">
Files stored in your root directory.
</Typography.Text>
</div>
<input
ref={fileInput}
className="hidden"
type="file"
onChange={handleFileSelected}
/>
<Button
type="primary"
icon={<UploadOutlined />}
loading={uploadMutation.isPending}
onClick={() => fileInput.current?.click()}
>
Upload file
</Button>
</div>
<Table<FileInfo>
rowKey="id"
columns={columns}
dataSource={filesQuery.data?.files ?? []}
loading={filesQuery.isLoading || filesQuery.isFetching}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No files in your root directory"
>
<Button
type="primary"
loading={uploadMutation.isPending}
onClick={() => fileInput.current?.click()}
>
Upload your first file
</Button>
</Empty>
),
}}
pagination={{
current: page,
pageSize,
total: filesQuery.data?.total ?? 0,
showSizeChanger: false,
showTotal: (total) => `${total} item${total === 1 ? '' : 's'}`,
onChange: setPage,
}}
scroll={{ x: 640 }}
/>
</Card>
)
}
-19
View File
@@ -1,19 +0,0 @@
import { Card, Space, Tag, Typography } from 'antd'
const { Paragraph, Title } = Typography
export function HomePage() {
return (
<main className="grid min-h-screen place-items-center bg-slate-50 p-6">
<Card className="w-full max-w-xl">
<Space orientation="vertical" size="middle">
<Tag color="blue">MyGO Web</Tag>
<Title level={1}>MyGO</Title>
<Paragraph>
The client-side web application foundation is ready.
</Paragraph>
</Space>
</Card>
</main>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { CloudOutlined, LockOutlined, MailOutlined } from '@ant-design/icons'
import { useMutation } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Input, Space, Typography } from 'antd'
import { Navigate, useLocation, useNavigate } from 'react-router'
import { login } from '../api/auth.ts'
import { ApiError } from '../api/client.ts'
import { sessionStore } from '../api/session.ts'
import { useAuthSession } from '../features/auth/useAuthSession.ts'
interface LoginValues {
email: string
password: string
}
interface LoginLocationState {
from?: unknown
}
function destinationFromState(state: unknown) {
const from = (state as LoginLocationState | null)?.from
return typeof from === 'string' && from.startsWith('/') ? from : '/'
}
export function LoginPage() {
const session = useAuthSession()
const location = useLocation()
const navigate = useNavigate()
const destination = destinationFromState(location.state)
const loginMutation = useMutation({
mutationFn: ({ email, password }: LoginValues) => login(email, password),
onSuccess: (tokenPair) => {
sessionStore.set(tokenPair)
navigate(destination, { replace: true })
},
})
if (session) {
return <Navigate to="/" replace />
}
const errorMessage = loginMutation.error
? loginMutation.error instanceof ApiError
? loginMutation.error.message
: 'Unable to reach the server'
: null
return (
<main className="grid min-h-screen place-items-center bg-slate-100 p-4">
<Card className="w-full max-w-md shadow-sm">
<Space orientation="vertical" size="large" className="w-full">
<div className="text-center">
<CloudOutlined className="mb-3 text-4xl text-blue-600" />
<Typography.Title level={1} className="mb-1!">
Sign in to MyGO
</Typography.Title>
<Typography.Text type="secondary">
Access your files from this browser session.
</Typography.Text>
</div>
{errorMessage && (
<Alert type="error" showIcon message="Sign-in failed" description={errorMessage} />
)}
<Form<LoginValues>
layout="vertical"
requiredMark={false}
onFinish={(values) => loginMutation.mutate(values)}
>
<Form.Item
label="Email"
name="email"
rules={[
{ required: true, message: 'Enter your email address' },
{ type: 'email', message: 'Enter a valid email address' },
]}
>
<Input
autoComplete="email"
prefix={<MailOutlined />}
placeholder="you@example.com"
size="large"
/>
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[{ required: true, message: 'Enter your password' }]}
>
<Input.Password
autoComplete="current-password"
prefix={<LockOutlined />}
placeholder="Password"
size="large"
/>
</Form.Item>
<Button
block
htmlType="submit"
loading={loginMutation.isPending}
size="large"
type="primary"
>
Sign in
</Button>
</Form>
</Space>
</Card>
</main>
)
}
+5
View File
@@ -4,4 +4,9 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://127.0.0.1:10086',
},
},
}) })
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.{ts,tsx}'],
},
})