Compare commits
11 Commits
27e651c4f5
...
evan/api-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c872264f | |||
| 0930054847 | |||
| aa812c6917 | |||
| 8ec3349b7c | |||
| decc3f0195 | |||
| b13f9b362c | |||
| 6c2c4f6b8b | |||
| d38392ac9a | |||
| 63ad73755d | |||
| 784e53c557 | |||
| 9ed63b2695 |
86
AGENTS.md
86
AGENTS.md
@@ -1,31 +1,75 @@
|
||||
# AnthoLume - Agent Context
|
||||
# AnthoLume Agent Guide
|
||||
|
||||
## Critical Rules
|
||||
## 1) Working Style
|
||||
|
||||
### Generated Files
|
||||
- **NEVER edit generated files directly** - Always edit the source and regenerate
|
||||
- Go backend API: Edit `api/v1/openapi.yaml` then run:
|
||||
- Keep changes targeted.
|
||||
- Do not refactor broadly unless the task requires it.
|
||||
- Validate only what is relevant to the change when practical.
|
||||
- If a fix will require substantial refactoring or wide-reaching changes, stop and ask first.
|
||||
|
||||
## 2) Hard Rules
|
||||
|
||||
- Never edit generated files directly.
|
||||
- Never write ad-hoc SQL.
|
||||
- For Go error wrapping, use `fmt.Errorf("message: %w", err)`.
|
||||
- Do not use `github.com/pkg/errors`.
|
||||
|
||||
## 3) Generated Code
|
||||
|
||||
### OpenAPI
|
||||
Edit:
|
||||
- `api/v1/openapi.yaml`
|
||||
|
||||
Regenerate:
|
||||
- `go generate ./api/v1/generate.go`
|
||||
- `cd frontend && bun run generate:api`
|
||||
- Examples of generated files:
|
||||
|
||||
Notes:
|
||||
- If you add response headers in `api/v1/openapi.yaml` (for example `Set-Cookie`), `oapi-codegen` will generate typed response header structs in `api/v1/api.gen.go`; update the handler response values to populate those headers explicitly.
|
||||
|
||||
Examples of generated files:
|
||||
- `api/v1/api.gen.go`
|
||||
- `frontend/src/generated/**/*.ts`
|
||||
|
||||
### Database Access
|
||||
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
|
||||
- Define queries in `database/query.sql` and regenerate via `sqlc generate`
|
||||
### SQLC
|
||||
Edit:
|
||||
- `database/query.sql`
|
||||
|
||||
### Error Handling
|
||||
- Use `fmt.Errorf("message: %w", err)` for wrapping errors
|
||||
- Do NOT use `github.com/pkg/errors`
|
||||
Regenerate:
|
||||
- `sqlc generate`
|
||||
|
||||
## Frontend
|
||||
- **Package manager**: bun (not npm)
|
||||
- **Icons**: Use custom icon components in `src/icons/` (not external icon libraries)
|
||||
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
|
||||
- **Format**: `cd frontend && bun run format` (and `format:fix`)
|
||||
- **Generate API client**: `cd frontend && bun run generate:api`
|
||||
## 4) Backend / Assets
|
||||
|
||||
## Regeneration
|
||||
- Go backend: `go generate ./api/v1/generate.go`
|
||||
- TS client: `cd frontend && bun run generate:api`
|
||||
### Common commands
|
||||
- Dev server: `make dev`
|
||||
- Direct dev run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve`
|
||||
- Tests: `make tests`
|
||||
- Tailwind asset build: `make build_tailwind`
|
||||
|
||||
### Notes
|
||||
- The Go server embeds `templates/*` and `assets/*`.
|
||||
- Root Tailwind output is built to `assets/style.css`.
|
||||
- Be mindful of whether a change affects the embedded server-rendered app, the React frontend, or both.
|
||||
- SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts.
|
||||
|
||||
## 5) Frontend
|
||||
|
||||
For frontend-specific implementation notes and commands, also read:
|
||||
- `frontend/AGENTS.md`
|
||||
|
||||
## 6) Regeneration Summary
|
||||
|
||||
- Go API: `go generate ./api/v1/generate.go`
|
||||
- Frontend API client: `cd frontend && bun run generate:api`
|
||||
- SQLC: `sqlc generate`
|
||||
|
||||
## 7) Updating This File
|
||||
|
||||
After completing a task, update this `AGENTS.md` if you learned something general that would help future agents.
|
||||
|
||||
Rules for updates:
|
||||
- Add only repository-wide guidance.
|
||||
- Do not add one-off task history.
|
||||
- Keep updates short, concrete, and organized.
|
||||
- Place new guidance in the most relevant section.
|
||||
- If the new information would help future agents avoid repeated mistakes, add it proactively.
|
||||
|
||||
@@ -2,7 +2,9 @@ package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/database"
|
||||
)
|
||||
|
||||
@@ -72,3 +74,78 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
||||
}
|
||||
return GetActivity200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
// POST /activity
|
||||
func (s *Server) CreateActivity(ctx context.Context, request CreateActivityRequestObject) (CreateActivityResponseObject, error) {
|
||||
auth, ok := s.getSessionFromContext(ctx)
|
||||
if !ok {
|
||||
return CreateActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return CreateActivity400JSONResponse{Code: 400, Message: "Request body is required"}, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.DB.Begin()
|
||||
if err != nil {
|
||||
log.Error("Transaction Begin DB Error:", err)
|
||||
return CreateActivity500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
log.Debug("Transaction Rollback DB Error:", rollbackErr)
|
||||
}
|
||||
}()
|
||||
|
||||
qtx := s.db.Queries.WithTx(tx)
|
||||
|
||||
allDocumentsMap := make(map[string]struct{})
|
||||
for _, item := range request.Body.Activity {
|
||||
allDocumentsMap[item.DocumentId] = struct{}{}
|
||||
}
|
||||
|
||||
for documentID := range allDocumentsMap {
|
||||
if _, err := qtx.UpsertDocument(ctx, database.UpsertDocumentParams{ID: documentID}); err != nil {
|
||||
log.Error("UpsertDocument DB Error:", err)
|
||||
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid document"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := qtx.UpsertDevice(ctx, database.UpsertDeviceParams{
|
||||
ID: request.Body.DeviceId,
|
||||
UserID: auth.UserName,
|
||||
DeviceName: request.Body.DeviceName,
|
||||
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Error("UpsertDevice DB Error:", err)
|
||||
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid device"}, nil
|
||||
}
|
||||
|
||||
for _, item := range request.Body.Activity {
|
||||
if _, err := qtx.AddActivity(ctx, database.AddActivityParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: item.DocumentId,
|
||||
DeviceID: request.Body.DeviceId,
|
||||
StartTime: time.Unix(item.StartTime, 0).UTC().Format(time.RFC3339),
|
||||
Duration: item.Duration,
|
||||
StartPercentage: float64(item.Page) / float64(item.Pages),
|
||||
EndPercentage: float64(item.Page+1) / float64(item.Pages),
|
||||
}); err != nil {
|
||||
log.Error("AddActivity DB Error:", err)
|
||||
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid activity"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Error("Transaction Commit DB Error:", err)
|
||||
return CreateActivity500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||
}
|
||||
committed = true
|
||||
|
||||
response := CreateActivityResponse{Added: int64(len(request.Body.Activity))}
|
||||
return CreateActivity200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
126
api/v1/admin.go
126
api/v1/admin.go
@@ -438,11 +438,10 @@ func (s *Server) GetUsers(ctx context.Context, request GetUsersRequestObject) (G
|
||||
|
||||
apiUsers := make([]User, len(users))
|
||||
for i, user := range users {
|
||||
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
||||
apiUsers[i] = User{
|
||||
Id: user.ID,
|
||||
Admin: user.Admin,
|
||||
CreatedAt: createdAt,
|
||||
CreatedAt: parseTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,11 +492,10 @@ func (s *Server) UpdateUser(ctx context.Context, request UpdateUserRequestObject
|
||||
|
||||
apiUsers := make([]User, len(users))
|
||||
for i, user := range users {
|
||||
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
|
||||
apiUsers[i] = User{
|
||||
Id: user.ID,
|
||||
Admin: user.Admin,
|
||||
CreatedAt: createdAt,
|
||||
CreatedAt: parseTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,7 +939,16 @@ func (s *Server) GetLogs(ctx context.Context, request GetLogsRequestObject) (Get
|
||||
return GetLogs401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
// Get filter parameter (mirroring legacy)
|
||||
page := int64(1)
|
||||
if request.Params.Page != nil && *request.Params.Page > 0 {
|
||||
page = *request.Params.Page
|
||||
}
|
||||
|
||||
limit := int64(100)
|
||||
if request.Params.Limit != nil && *request.Params.Limit > 0 {
|
||||
limit = *request.Params.Limit
|
||||
}
|
||||
|
||||
filter := ""
|
||||
if request.Params.Filter != nil {
|
||||
filter = strings.TrimSpace(*request.Params.Filter)
|
||||
@@ -967,7 +974,6 @@ func (s *Server) GetLogs(ctx context.Context, request GetLogsRequestObject) (Get
|
||||
}
|
||||
}
|
||||
|
||||
// Open Log File (mirroring legacy)
|
||||
logPath := filepath.Join(s.cfg.ConfigPath, "logs/antholume.log")
|
||||
logFile, err := os.Open(logPath)
|
||||
if err != nil {
|
||||
@@ -975,58 +981,90 @@ func (s *Server) GetLogs(ctx context.Context, request GetLogsRequestObject) (Get
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
// Log Lines (mirroring legacy)
|
||||
var logLines []string
|
||||
offset := (page - 1) * limit
|
||||
logLines := make([]string, 0, limit)
|
||||
matchedCount := int64(0)
|
||||
|
||||
scanner := bufio.NewScanner(logFile)
|
||||
for scanner.Scan() {
|
||||
rawLog := scanner.Text()
|
||||
|
||||
// Attempt JSON Pretty (mirroring legacy)
|
||||
var jsonMap map[string]any
|
||||
err := json.Unmarshal([]byte(rawLog), &jsonMap)
|
||||
if err != nil {
|
||||
logLines = append(logLines, rawLog)
|
||||
formattedLog, matched := formatLogLine(scanner.Text(), basicFilter, jqFilter)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse JSON (mirroring legacy)
|
||||
rawData, err := json.MarshalIndent(jsonMap, "", " ")
|
||||
if err != nil {
|
||||
logLines = append(logLines, rawLog)
|
||||
continue
|
||||
if matchedCount >= offset && int64(len(logLines)) < limit {
|
||||
logLines = append(logLines, formattedLog)
|
||||
}
|
||||
matchedCount++
|
||||
}
|
||||
|
||||
// Basic Filter (mirroring legacy)
|
||||
if basicFilter != "" && strings.Contains(string(rawData), basicFilter) {
|
||||
logLines = append(logLines, string(rawData))
|
||||
continue
|
||||
if err := scanner.Err(); err != nil {
|
||||
return GetLogs500JSONResponse{Code: 500, Message: "Unable to read AnthoLume log file"}, nil
|
||||
}
|
||||
|
||||
// No JQ Filter (mirroring legacy)
|
||||
if jqFilter == nil {
|
||||
continue
|
||||
var nextPage *int64
|
||||
var previousPage *int64
|
||||
if page > 1 {
|
||||
previousPage = ptrOf(page - 1)
|
||||
}
|
||||
|
||||
// Error or nil (mirroring legacy)
|
||||
result, _ := jqFilter.Run(jsonMap).Next()
|
||||
if _, ok := result.(error); ok {
|
||||
logLines = append(logLines, string(rawData))
|
||||
continue
|
||||
} else if result == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Attempt filtered json (mirroring legacy)
|
||||
filteredData, err := json.MarshalIndent(result, "", " ")
|
||||
if err == nil {
|
||||
rawData = filteredData
|
||||
}
|
||||
|
||||
logLines = append(logLines, string(rawData))
|
||||
if offset+int64(len(logLines)) < matchedCount {
|
||||
nextPage = ptrOf(page + 1)
|
||||
}
|
||||
|
||||
return GetLogs200JSONResponse{
|
||||
Logs: &logLines,
|
||||
Filter: &filter,
|
||||
Page: &page,
|
||||
Limit: &limit,
|
||||
NextPage: nextPage,
|
||||
PreviousPage: previousPage,
|
||||
Total: &matchedCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatLogLine(rawLog string, basicFilter string, jqFilter *gojq.Code) (string, bool) {
|
||||
var jsonMap map[string]any
|
||||
if err := json.Unmarshal([]byte(rawLog), &jsonMap); err != nil {
|
||||
if basicFilter == "" && jqFilter == nil {
|
||||
return rawLog, true
|
||||
}
|
||||
if basicFilter != "" && strings.Contains(rawLog, basicFilter) {
|
||||
return rawLog, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
rawData, err := json.MarshalIndent(jsonMap, "", " ")
|
||||
if err != nil {
|
||||
if basicFilter == "" && jqFilter == nil {
|
||||
return rawLog, true
|
||||
}
|
||||
if basicFilter != "" && strings.Contains(rawLog, basicFilter) {
|
||||
return rawLog, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
formattedLog := string(rawData)
|
||||
if basicFilter != "" {
|
||||
return formattedLog, strings.Contains(formattedLog, basicFilter)
|
||||
}
|
||||
if jqFilter == nil {
|
||||
return formattedLog, true
|
||||
}
|
||||
|
||||
result, _ := jqFilter.Run(jsonMap).Next()
|
||||
if _, ok := result.(error); ok {
|
||||
return formattedLog, true
|
||||
}
|
||||
if result == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
filteredData, err := json.MarshalIndent(result, "", " ")
|
||||
if err == nil {
|
||||
formattedLog = string(filteredData)
|
||||
}
|
||||
|
||||
return formattedLog, true
|
||||
}
|
||||
|
||||
152
api/v1/admin_test.go
Normal file
152
api/v1/admin_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
argon2 "github.com/alexedwards/argon2id"
|
||||
"github.com/stretchr/testify/require"
|
||||
"reichard.io/antholume/config"
|
||||
"reichard.io/antholume/database"
|
||||
)
|
||||
|
||||
func createAdminTestUser(t *testing.T, db *database.DBManager, username, password string) {
|
||||
t.Helper()
|
||||
|
||||
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
|
||||
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
|
||||
require.NoError(t, err)
|
||||
|
||||
authHash := "test-auth-hash"
|
||||
_, err = db.Queries.CreateUser(context.Background(), database.CreateUserParams{
|
||||
ID: username,
|
||||
Pass: &hashedPassword,
|
||||
AuthHash: &authHash,
|
||||
Admin: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func loginAdminTestUser(t *testing.T, srv *Server, username, password string) *http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(LoginRequest{Username: username, Password: password})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
cookies := w.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
|
||||
return cookies[0]
|
||||
}
|
||||
|
||||
func TestGetLogsPagination(t *testing.T) {
|
||||
configPath := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(configPath, "logs"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configPath, "logs", "antholume.log"), []byte(
|
||||
"{\"level\":\"info\",\"msg\":\"one\"}\n"+
|
||||
"plain two\n"+
|
||||
"{\"level\":\"error\",\"msg\":\"three\"}\n"+
|
||||
"plain four\n",
|
||||
), 0o644))
|
||||
|
||||
cfg := &config.Config{
|
||||
ListenPort: "8080",
|
||||
DBType: "memory",
|
||||
DBName: "test",
|
||||
ConfigPath: configPath,
|
||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||
CookieEncKey: "0123456789abcdef",
|
||||
CookieSecure: false,
|
||||
CookieHTTPOnly: true,
|
||||
Version: "test",
|
||||
DemoMode: false,
|
||||
RegistrationEnabled: true,
|
||||
}
|
||||
|
||||
db := database.NewMgr(cfg)
|
||||
srv := NewServer(db, cfg, nil)
|
||||
createAdminTestUser(t, db, "admin", "password")
|
||||
cookie := loginAdminTestUser(t, srv, "admin", "password")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/logs?page=2&limit=2", nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp LogsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
require.NotNil(t, resp.Logs)
|
||||
require.Len(t, *resp.Logs, 2)
|
||||
require.NotNil(t, resp.Page)
|
||||
require.Equal(t, int64(2), *resp.Page)
|
||||
require.NotNil(t, resp.Limit)
|
||||
require.Equal(t, int64(2), *resp.Limit)
|
||||
require.NotNil(t, resp.Total)
|
||||
require.Equal(t, int64(4), *resp.Total)
|
||||
require.Nil(t, resp.NextPage)
|
||||
require.NotNil(t, resp.PreviousPage)
|
||||
require.Equal(t, int64(1), *resp.PreviousPage)
|
||||
require.Contains(t, (*resp.Logs)[0], "three")
|
||||
require.Contains(t, (*resp.Logs)[1], "plain four")
|
||||
}
|
||||
|
||||
func TestGetLogsPaginationWithBasicFilter(t *testing.T) {
|
||||
configPath := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(configPath, "logs"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configPath, "logs", "antholume.log"), []byte(
|
||||
"{\"level\":\"info\",\"msg\":\"match-1\"}\n"+
|
||||
"{\"level\":\"info\",\"msg\":\"skip\"}\n"+
|
||||
"plain match-2\n"+
|
||||
"{\"level\":\"info\",\"msg\":\"match-3\"}\n",
|
||||
), 0o644))
|
||||
|
||||
cfg := &config.Config{
|
||||
ListenPort: "8080",
|
||||
DBType: "memory",
|
||||
DBName: "test",
|
||||
ConfigPath: configPath,
|
||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||
CookieEncKey: "0123456789abcdef",
|
||||
CookieSecure: false,
|
||||
CookieHTTPOnly: true,
|
||||
Version: "test",
|
||||
DemoMode: false,
|
||||
RegistrationEnabled: true,
|
||||
}
|
||||
|
||||
db := database.NewMgr(cfg)
|
||||
srv := NewServer(db, cfg, nil)
|
||||
createAdminTestUser(t, db, "admin", "password")
|
||||
cookie := loginAdminTestUser(t, srv, "admin", "password")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/logs?filter=%22match%22&page=1&limit=2", nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp LogsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
require.NotNil(t, resp.Logs)
|
||||
require.Len(t, *resp.Logs, 2)
|
||||
require.NotNil(t, resp.Total)
|
||||
require.Equal(t, int64(3), *resp.Total)
|
||||
require.NotNil(t, resp.NextPage)
|
||||
require.Equal(t, int64(2), *resp.NextPage)
|
||||
}
|
||||
@@ -164,6 +164,27 @@ type ActivityResponse struct {
|
||||
// BackupType defines model for BackupType.
|
||||
type BackupType string
|
||||
|
||||
// CreateActivityItem defines model for CreateActivityItem.
|
||||
type CreateActivityItem struct {
|
||||
DocumentId string `json:"document_id"`
|
||||
Duration int64 `json:"duration"`
|
||||
Page int64 `json:"page"`
|
||||
Pages int64 `json:"pages"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
}
|
||||
|
||||
// CreateActivityRequest defines model for CreateActivityRequest.
|
||||
type CreateActivityRequest struct {
|
||||
Activity []CreateActivityItem `json:"activity"`
|
||||
DeviceId string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
// CreateActivityResponse defines model for CreateActivityResponse.
|
||||
type CreateActivityResponse struct {
|
||||
Added int64 `json:"added"`
|
||||
}
|
||||
|
||||
// DatabaseInfo defines model for DatabaseInfo.
|
||||
type DatabaseInfo struct {
|
||||
ActivitySize int64 `json:"activity_size"`
|
||||
@@ -215,7 +236,6 @@ type Document struct {
|
||||
// DocumentResponse defines model for DocumentResponse.
|
||||
type DocumentResponse struct {
|
||||
Document Document `json:"document"`
|
||||
Progress *Progress `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentsResponse defines model for DocumentsResponse.
|
||||
@@ -227,8 +247,6 @@ type DocumentsResponse struct {
|
||||
PreviousPage *int64 `json:"previous_page,omitempty"`
|
||||
Search *string `json:"search,omitempty"`
|
||||
Total int64 `json:"total"`
|
||||
User UserData `json:"user"`
|
||||
WordCounts []WordCount `json:"word_counts"`
|
||||
}
|
||||
|
||||
// ErrorResponse defines model for ErrorResponse.
|
||||
@@ -315,7 +333,12 @@ type LoginResponse struct {
|
||||
// LogsResponse defines model for LogsResponse.
|
||||
type LogsResponse struct {
|
||||
Filter *string `json:"filter,omitempty"`
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
Logs *[]LogEntry `json:"logs,omitempty"`
|
||||
NextPage *int64 `json:"next_page,omitempty"`
|
||||
Page *int64 `json:"page,omitempty"`
|
||||
PreviousPage *int64 `json:"previous_page,omitempty"`
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// MessageResponse defines model for MessageResponse.
|
||||
@@ -330,9 +353,11 @@ type OperationType string
|
||||
type Progress struct {
|
||||
Author *string `json:"author,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
DeviceName *string `json:"device_name,omitempty"`
|
||||
DocumentId *string `json:"document_id,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
Progress *string `json:"progress,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
UserId *string `json:"user_id,omitempty"`
|
||||
}
|
||||
@@ -383,6 +408,21 @@ type StreaksResponse struct {
|
||||
Streaks []UserStreak `json:"streaks"`
|
||||
}
|
||||
|
||||
// UpdateProgressRequest defines model for UpdateProgressRequest.
|
||||
type UpdateProgressRequest struct {
|
||||
DeviceId string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DocumentId string `json:"document_id"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
// UpdateProgressResponse defines model for UpdateProgressResponse.
|
||||
type UpdateProgressResponse struct {
|
||||
DocumentId string `json:"document_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// UpdateSettingsRequest defines model for UpdateSettingsRequest.
|
||||
type UpdateSettingsRequest struct {
|
||||
NewPassword *string `json:"new_password,omitempty"`
|
||||
@@ -426,12 +466,6 @@ type UsersResponse struct {
|
||||
Users *[]User `json:"users,omitempty"`
|
||||
}
|
||||
|
||||
// WordCount defines model for WordCount.
|
||||
type WordCount struct {
|
||||
Count int64 `json:"count"`
|
||||
DocumentId string `json:"document_id"`
|
||||
}
|
||||
|
||||
// GetActivityParams defines parameters for GetActivity.
|
||||
type GetActivityParams struct {
|
||||
DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"`
|
||||
@@ -465,6 +499,8 @@ type PostImportFormdataBody struct {
|
||||
// GetLogsParams defines parameters for GetLogs.
|
||||
type GetLogsParams struct {
|
||||
Filter *string `form:"filter,omitempty" json:"filter,omitempty"`
|
||||
Page *int64 `form:"page,omitempty" json:"page,omitempty"`
|
||||
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserFormdataBody defines parameters for UpdateUser.
|
||||
@@ -526,6 +562,9 @@ type PostSearchFormdataBody struct {
|
||||
Title string `form:"title" json:"title"`
|
||||
}
|
||||
|
||||
// CreateActivityJSONRequestBody defines body for CreateActivity for application/json ContentType.
|
||||
type CreateActivityJSONRequestBody = CreateActivityRequest
|
||||
|
||||
// PostAdminActionMultipartRequestBody defines body for PostAdminAction for multipart/form-data ContentType.
|
||||
type PostAdminActionMultipartRequestBody PostAdminActionMultipartBody
|
||||
|
||||
@@ -550,6 +589,9 @@ type EditDocumentJSONRequestBody EditDocumentJSONBody
|
||||
// UploadDocumentCoverMultipartRequestBody defines body for UploadDocumentCover for multipart/form-data ContentType.
|
||||
type UploadDocumentCoverMultipartRequestBody UploadDocumentCoverMultipartBody
|
||||
|
||||
// UpdateProgressJSONRequestBody defines body for UpdateProgress for application/json ContentType.
|
||||
type UpdateProgressJSONRequestBody = UpdateProgressRequest
|
||||
|
||||
// PostSearchFormdataRequestBody defines body for PostSearch for application/x-www-form-urlencoded ContentType.
|
||||
type PostSearchFormdataRequestBody PostSearchFormdataBody
|
||||
|
||||
@@ -561,6 +603,9 @@ type ServerInterface interface {
|
||||
// Get activity data
|
||||
// (GET /activity)
|
||||
GetActivity(w http.ResponseWriter, r *http.Request, params GetActivityParams)
|
||||
// Create activity records
|
||||
// (POST /activity)
|
||||
CreateActivity(w http.ResponseWriter, r *http.Request)
|
||||
// Get admin page data
|
||||
// (GET /admin)
|
||||
GetAdmin(w http.ResponseWriter, r *http.Request)
|
||||
@@ -636,6 +681,9 @@ type ServerInterface interface {
|
||||
// List progress records
|
||||
// (GET /progress)
|
||||
GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams)
|
||||
// Update document progress
|
||||
// (PUT /progress)
|
||||
UpdateProgress(w http.ResponseWriter, r *http.Request)
|
||||
// Get document progress
|
||||
// (GET /progress/{id})
|
||||
GetProgress(w http.ResponseWriter, r *http.Request, id string)
|
||||
@@ -719,6 +767,26 @@ func (siw *ServerInterfaceWrapper) GetActivity(w http.ResponseWriter, r *http.Re
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// CreateActivity operation middleware
|
||||
func (siw *ServerInterfaceWrapper) CreateActivity(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.CreateActivity(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetAdmin operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -862,6 +930,22 @@ func (siw *ServerInterfaceWrapper) GetLogs(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "page" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "page", r.URL.Query(), ¶ms.Page, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "limit" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetLogs(w, r, params)
|
||||
}))
|
||||
@@ -1348,6 +1432,26 @@ func (siw *ServerInterfaceWrapper) GetProgressList(w http.ResponseWriter, r *htt
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// UpdateProgress operation middleware
|
||||
func (siw *ServerInterfaceWrapper) UpdateProgress(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.UpdateProgress(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetProgress operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1615,6 +1719,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
}
|
||||
|
||||
m.HandleFunc("GET "+options.BaseURL+"/activity", wrapper.GetActivity)
|
||||
m.HandleFunc("POST "+options.BaseURL+"/activity", wrapper.CreateActivity)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/admin", wrapper.GetAdmin)
|
||||
m.HandleFunc("POST "+options.BaseURL+"/admin", wrapper.PostAdminAction)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/admin/import", wrapper.GetImportDirectory)
|
||||
@@ -1640,6 +1745,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc("GET "+options.BaseURL+"/home/streaks", wrapper.GetStreaks)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/info", wrapper.GetInfo)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/progress", wrapper.GetProgressList)
|
||||
m.HandleFunc("PUT "+options.BaseURL+"/progress", wrapper.UpdateProgress)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/progress/{id}", wrapper.GetProgress)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/search", wrapper.GetSearch)
|
||||
m.HandleFunc("POST "+options.BaseURL+"/search", wrapper.PostSearch)
|
||||
@@ -1684,6 +1790,50 @@ func (response GetActivity500JSONResponse) VisitGetActivityResponse(w http.Respo
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type CreateActivityRequestObject struct {
|
||||
Body *CreateActivityJSONRequestBody
|
||||
}
|
||||
|
||||
type CreateActivityResponseObject interface {
|
||||
VisitCreateActivityResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type CreateActivity200JSONResponse CreateActivityResponse
|
||||
|
||||
func (response CreateActivity200JSONResponse) VisitCreateActivityResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type CreateActivity400JSONResponse ErrorResponse
|
||||
|
||||
func (response CreateActivity400JSONResponse) VisitCreateActivityResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(400)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type CreateActivity401JSONResponse ErrorResponse
|
||||
|
||||
func (response CreateActivity401JSONResponse) VisitCreateActivityResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(401)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type CreateActivity500JSONResponse ErrorResponse
|
||||
|
||||
func (response CreateActivity500JSONResponse) VisitCreateActivityResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type GetAdminRequestObject struct {
|
||||
}
|
||||
|
||||
@@ -2008,13 +2158,21 @@ type LoginResponseObject interface {
|
||||
VisitLoginResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type Login200JSONResponse LoginResponse
|
||||
type Login200ResponseHeaders struct {
|
||||
SetCookie string
|
||||
}
|
||||
|
||||
type Login200JSONResponse struct {
|
||||
Body LoginResponse
|
||||
Headers Login200ResponseHeaders
|
||||
}
|
||||
|
||||
func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type Login400JSONResponse ErrorResponse
|
||||
@@ -2101,13 +2259,21 @@ type RegisterResponseObject interface {
|
||||
VisitRegisterResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type Register201JSONResponse LoginResponse
|
||||
type Register201ResponseHeaders struct {
|
||||
SetCookie string
|
||||
}
|
||||
|
||||
type Register201JSONResponse struct {
|
||||
Body LoginResponse
|
||||
Headers Register201ResponseHeaders
|
||||
}
|
||||
|
||||
func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
|
||||
w.WriteHeader(201)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type Register400JSONResponse ErrorResponse
|
||||
@@ -2691,6 +2857,50 @@ func (response GetProgressList500JSONResponse) VisitGetProgressListResponse(w ht
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type UpdateProgressRequestObject struct {
|
||||
Body *UpdateProgressJSONRequestBody
|
||||
}
|
||||
|
||||
type UpdateProgressResponseObject interface {
|
||||
VisitUpdateProgressResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type UpdateProgress200JSONResponse UpdateProgressResponse
|
||||
|
||||
func (response UpdateProgress200JSONResponse) VisitUpdateProgressResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type UpdateProgress400JSONResponse ErrorResponse
|
||||
|
||||
func (response UpdateProgress400JSONResponse) VisitUpdateProgressResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(400)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type UpdateProgress401JSONResponse ErrorResponse
|
||||
|
||||
func (response UpdateProgress401JSONResponse) VisitUpdateProgressResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(401)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type UpdateProgress500JSONResponse ErrorResponse
|
||||
|
||||
func (response UpdateProgress500JSONResponse) VisitUpdateProgressResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type GetProgressRequestObject struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
@@ -2896,6 +3106,9 @@ type StrictServerInterface interface {
|
||||
// Get activity data
|
||||
// (GET /activity)
|
||||
GetActivity(ctx context.Context, request GetActivityRequestObject) (GetActivityResponseObject, error)
|
||||
// Create activity records
|
||||
// (POST /activity)
|
||||
CreateActivity(ctx context.Context, request CreateActivityRequestObject) (CreateActivityResponseObject, error)
|
||||
// Get admin page data
|
||||
// (GET /admin)
|
||||
GetAdmin(ctx context.Context, request GetAdminRequestObject) (GetAdminResponseObject, error)
|
||||
@@ -2971,6 +3184,9 @@ type StrictServerInterface interface {
|
||||
// List progress records
|
||||
// (GET /progress)
|
||||
GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error)
|
||||
// Update document progress
|
||||
// (PUT /progress)
|
||||
UpdateProgress(ctx context.Context, request UpdateProgressRequestObject) (UpdateProgressResponseObject, error)
|
||||
// Get document progress
|
||||
// (GET /progress/{id})
|
||||
GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error)
|
||||
@@ -3043,6 +3259,37 @@ func (sh *strictHandler) GetActivity(w http.ResponseWriter, r *http.Request, par
|
||||
}
|
||||
}
|
||||
|
||||
// CreateActivity operation middleware
|
||||
func (sh *strictHandler) CreateActivity(w http.ResponseWriter, r *http.Request) {
|
||||
var request CreateActivityRequestObject
|
||||
|
||||
var body CreateActivityJSONRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.CreateActivity(ctx, request.(CreateActivityRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "CreateActivity")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(CreateActivityResponseObject); ok {
|
||||
if err := validResponse.VisitCreateActivityResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// GetAdmin operation middleware
|
||||
func (sh *strictHandler) GetAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
var request GetAdminRequestObject
|
||||
@@ -3725,6 +3972,37 @@ func (sh *strictHandler) GetProgressList(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProgress operation middleware
|
||||
func (sh *strictHandler) UpdateProgress(w http.ResponseWriter, r *http.Request) {
|
||||
var request UpdateProgressRequestObject
|
||||
|
||||
var body UpdateProgressJSONRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.UpdateProgress(ctx, request.(UpdateProgressRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "UpdateProgress")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(UpdateProgressResponseObject); ok {
|
||||
if err := validResponse.VisitUpdateProgressResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// GetProgress operation middleware
|
||||
func (sh *strictHandler) GetProgress(w http.ResponseWriter, r *http.Request, id string) {
|
||||
var request GetProgressRequestObject
|
||||
|
||||
@@ -41,8 +41,13 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
|
||||
}
|
||||
|
||||
return Login200JSONResponse{
|
||||
Body: LoginResponse{
|
||||
Username: user.ID,
|
||||
IsAdmin: user.Admin,
|
||||
},
|
||||
Headers: Login200ResponseHeaders{
|
||||
SetCookie: s.getSetCookieFromContext(ctx),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -81,8 +86,13 @@ func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (R
|
||||
}
|
||||
|
||||
return Register201JSONResponse{
|
||||
Body: LoginResponse{
|
||||
Username: user.ID,
|
||||
IsAdmin: user.Admin,
|
||||
},
|
||||
Headers: Register201ResponseHeaders{
|
||||
SetCookie: s.getSetCookieFromContext(ctx),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -207,6 +217,14 @@ func (s *Server) getResponseWriterFromContext(ctx context.Context) http.Response
|
||||
return w
|
||||
}
|
||||
|
||||
func (s *Server) getSetCookieFromContext(ctx context.Context) string {
|
||||
w := s.getResponseWriterFromContext(ctx)
|
||||
if w == nil {
|
||||
return ""
|
||||
}
|
||||
return w.Header().Get("Set-Cookie")
|
||||
}
|
||||
|
||||
// getSession retrieves auth data from the session cookie
|
||||
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
|
||||
// Get session from cookie store
|
||||
|
||||
@@ -66,6 +66,13 @@ func (suite *AuthTestSuite) createTestUser(username, password string) {
|
||||
suite.Require().NoError(err)
|
||||
}
|
||||
|
||||
func (suite *AuthTestSuite) assertSessionCookie(cookie *http.Cookie) {
|
||||
suite.Require().NotNil(cookie)
|
||||
suite.Equal("token", cookie.Name)
|
||||
suite.NotEmpty(cookie.Value)
|
||||
suite.True(cookie.HttpOnly)
|
||||
}
|
||||
|
||||
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
|
||||
reqBody := LoginRequest{
|
||||
Username: username,
|
||||
@@ -86,6 +93,7 @@ func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
suite.Require().Len(cookies, 1, "should have session cookie")
|
||||
suite.assertSessionCookie(cookies[0])
|
||||
|
||||
return cookies[0]
|
||||
}
|
||||
@@ -109,6 +117,10 @@ func (suite *AuthTestSuite) TestAPILogin() {
|
||||
var resp LoginResponse
|
||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
suite.Equal("testuser", resp.Username)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
suite.Require().Len(cookies, 1)
|
||||
suite.assertSessionCookie(cookies[0])
|
||||
}
|
||||
|
||||
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
|
||||
@@ -146,7 +158,8 @@ func (suite *AuthTestSuite) TestAPIRegister() {
|
||||
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
suite.Require().NotEmpty(cookies, "register should set a session cookie")
|
||||
suite.Require().Len(cookies, 1, "register should set a session cookie")
|
||||
suite.assertSessionCookie(cookies[0])
|
||||
|
||||
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
|
||||
suite.Require().NoError(err)
|
||||
@@ -182,6 +195,10 @@ func (suite *AuthTestSuite) TestAPILogout() {
|
||||
suite.srv.ServeHTTP(w, req)
|
||||
|
||||
suite.Equal(http.StatusOK, w.Code)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
suite.Require().Len(cookies, 1)
|
||||
suite.Equal("token", cookies[0].Name)
|
||||
}
|
||||
|
||||
func (suite *AuthTestSuite) TestAPIGetMe() {
|
||||
|
||||
@@ -63,7 +63,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
}
|
||||
|
||||
apiDocuments := make([]Document, len(rows))
|
||||
wordCounts := make([]WordCount, 0, len(rows))
|
||||
for i, row := range rows {
|
||||
apiDocuments[i] = Document{
|
||||
Id: row.ID,
|
||||
@@ -83,12 +82,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
|
||||
Deleted: false, // Default, should be overridden if available
|
||||
}
|
||||
if row.Words != nil {
|
||||
wordCounts = append(wordCounts, WordCount{
|
||||
DocumentId: row.ID,
|
||||
Count: *row.Words,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response := DocumentsResponse{
|
||||
@@ -99,8 +92,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
NextPage: nextPage,
|
||||
PreviousPage: previousPage,
|
||||
Search: request.Params.Search,
|
||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||
WordCounts: wordCounts,
|
||||
}
|
||||
return GetDocuments200JSONResponse(response), nil
|
||||
}
|
||||
@@ -129,21 +120,6 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
||||
|
||||
doc := docs[0]
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: request.Id,
|
||||
})
|
||||
var progress *Progress
|
||||
if err == nil {
|
||||
progress = &Progress{
|
||||
UserId: &progressRow.UserID,
|
||||
DocumentId: &progressRow.DocumentID,
|
||||
DeviceName: &progressRow.DeviceName,
|
||||
Percentage: &progressRow.Percentage,
|
||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||
}
|
||||
}
|
||||
|
||||
apiDoc := Document{
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
@@ -165,7 +141,6 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
||||
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
Progress: progress,
|
||||
}
|
||||
return GetDocument200JSONResponse(response), nil
|
||||
}
|
||||
@@ -244,20 +219,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
||||
|
||||
doc := docs[0]
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: request.Id,
|
||||
})
|
||||
var progress *Progress
|
||||
if err == nil {
|
||||
progress = &Progress{
|
||||
UserId: &progressRow.UserID,
|
||||
DocumentId: &progressRow.DocumentID,
|
||||
DeviceName: &progressRow.DeviceName,
|
||||
Percentage: &progressRow.Percentage,
|
||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||
}
|
||||
}
|
||||
|
||||
apiDoc := Document{
|
||||
Id: doc.ID,
|
||||
@@ -280,7 +241,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
||||
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
Progress: progress,
|
||||
}
|
||||
return EditDocument200JSONResponse(response), nil
|
||||
}
|
||||
@@ -601,20 +561,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
|
||||
|
||||
doc := docs[0]
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: request.Id,
|
||||
})
|
||||
var progress *Progress
|
||||
if err == nil {
|
||||
progress = &Progress{
|
||||
UserId: &progressRow.UserID,
|
||||
DocumentId: &progressRow.DocumentID,
|
||||
DeviceName: &progressRow.DeviceName,
|
||||
Percentage: &progressRow.Percentage,
|
||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||
}
|
||||
}
|
||||
|
||||
apiDoc := Document{
|
||||
Id: doc.ID,
|
||||
@@ -637,7 +583,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
|
||||
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
Progress: progress,
|
||||
}
|
||||
return UploadDocumentCover200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
|
||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
suite.Equal(int64(1), resp.Page)
|
||||
suite.Equal(int64(9), resp.Limit)
|
||||
suite.Equal("testuser", resp.User.Username)
|
||||
}
|
||||
|
||||
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {
|
||||
|
||||
@@ -92,9 +92,13 @@ components:
|
||||
type: string
|
||||
device_name:
|
||||
type: string
|
||||
device_id:
|
||||
type: string
|
||||
percentage:
|
||||
type: number
|
||||
format: double
|
||||
progress:
|
||||
type: string
|
||||
document_id:
|
||||
type: string
|
||||
user_id:
|
||||
@@ -103,6 +107,88 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
UpdateProgressRequest:
|
||||
type: object
|
||||
properties:
|
||||
document_id:
|
||||
type: string
|
||||
percentage:
|
||||
type: number
|
||||
format: double
|
||||
progress:
|
||||
type: string
|
||||
device_id:
|
||||
type: string
|
||||
device_name:
|
||||
type: string
|
||||
required:
|
||||
- document_id
|
||||
- percentage
|
||||
- progress
|
||||
- device_id
|
||||
- device_name
|
||||
|
||||
UpdateProgressResponse:
|
||||
type: object
|
||||
properties:
|
||||
document_id:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- document_id
|
||||
- timestamp
|
||||
|
||||
CreateActivityItem:
|
||||
type: object
|
||||
properties:
|
||||
document_id:
|
||||
type: string
|
||||
start_time:
|
||||
type: integer
|
||||
format: int64
|
||||
duration:
|
||||
type: integer
|
||||
format: int64
|
||||
page:
|
||||
type: integer
|
||||
format: int64
|
||||
pages:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- document_id
|
||||
- start_time
|
||||
- duration
|
||||
- page
|
||||
- pages
|
||||
|
||||
CreateActivityRequest:
|
||||
type: object
|
||||
properties:
|
||||
device_id:
|
||||
type: string
|
||||
device_name:
|
||||
type: string
|
||||
activity:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CreateActivityItem'
|
||||
required:
|
||||
- device_id
|
||||
- device_name
|
||||
- activity
|
||||
|
||||
CreateActivityResponse:
|
||||
type: object
|
||||
properties:
|
||||
added:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- added
|
||||
|
||||
Activity:
|
||||
type: object
|
||||
properties:
|
||||
@@ -214,27 +300,17 @@ components:
|
||||
format: int64
|
||||
search:
|
||||
type: string
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
word_counts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WordCount'
|
||||
required:
|
||||
- documents
|
||||
- total
|
||||
- page
|
||||
- limit
|
||||
- user
|
||||
- word_counts
|
||||
|
||||
DocumentResponse:
|
||||
type: object
|
||||
properties:
|
||||
document:
|
||||
$ref: '#/components/schemas/Document'
|
||||
progress:
|
||||
$ref: '#/components/schemas/Progress'
|
||||
required:
|
||||
- document
|
||||
|
||||
@@ -594,6 +670,21 @@ components:
|
||||
$ref: '#/components/schemas/LogEntry'
|
||||
filter:
|
||||
type: string
|
||||
page:
|
||||
type: integer
|
||||
format: int64
|
||||
limit:
|
||||
type: integer
|
||||
format: int64
|
||||
next_page:
|
||||
type: integer
|
||||
format: int64
|
||||
previous_page:
|
||||
type: integer
|
||||
format: int64
|
||||
total:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
InfoResponse:
|
||||
type: object
|
||||
@@ -611,8 +702,9 @@ components:
|
||||
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: token
|
||||
|
||||
paths:
|
||||
/documents:
|
||||
@@ -987,6 +1079,44 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
put:
|
||||
summary: Update document progress
|
||||
operationId: updateProgress
|
||||
tags:
|
||||
- Progress
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateProgressRequest'
|
||||
responses:
|
||||
200:
|
||||
description: Progress updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateProgressResponse'
|
||||
400:
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/progress/{id}:
|
||||
get:
|
||||
@@ -1077,6 +1207,44 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
post:
|
||||
summary: Create activity records
|
||||
operationId: createActivity
|
||||
tags:
|
||||
- Activity
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateActivityRequest'
|
||||
responses:
|
||||
200:
|
||||
description: Activity created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateActivityResponse'
|
||||
400:
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/settings:
|
||||
get:
|
||||
@@ -1159,6 +1327,11 @@ paths:
|
||||
responses:
|
||||
200:
|
||||
description: Successful login
|
||||
headers:
|
||||
Set-Cookie:
|
||||
description: HttpOnly session cookie for authenticated requests.
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1197,6 +1370,11 @@ paths:
|
||||
responses:
|
||||
201:
|
||||
description: Successful registration
|
||||
headers:
|
||||
Set-Cookie:
|
||||
description: HttpOnly session cookie for authenticated requests.
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1764,6 +1942,18 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
|
||||
@@ -3,9 +3,10 @@ package v1
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"reichard.io/antholume/database"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/database"
|
||||
)
|
||||
|
||||
// GET /progress
|
||||
@@ -87,30 +88,20 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
||||
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
filter := database.GetProgressParams{
|
||||
row, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocFilter: true,
|
||||
DocumentID: request.Id,
|
||||
Offset: 0,
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
progress, err := s.db.Queries.GetProgress(ctx, filter)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("GetProgress DB Error:", err)
|
||||
log.Error("GetDocumentProgress DB Error:", err)
|
||||
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
||||
}
|
||||
|
||||
if len(progress) == 0 {
|
||||
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
||||
}
|
||||
|
||||
row := progress[0]
|
||||
apiProgress := Progress{
|
||||
Title: row.Title,
|
||||
Author: row.Author,
|
||||
DeviceName: &row.DeviceName,
|
||||
DeviceId: &row.DeviceID,
|
||||
Percentage: &row.Percentage,
|
||||
Progress: &row.Progress,
|
||||
DocumentId: &row.DocumentID,
|
||||
UserId: &row.UserID,
|
||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||
@@ -122,3 +113,51 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
||||
|
||||
return GetProgress200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
// PUT /progress
|
||||
func (s *Server) UpdateProgress(ctx context.Context, request UpdateProgressRequestObject) (UpdateProgressResponseObject, error) {
|
||||
auth, ok := s.getSessionFromContext(ctx)
|
||||
if !ok {
|
||||
return UpdateProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return UpdateProgress400JSONResponse{Code: 400, Message: "Request body is required"}, nil
|
||||
}
|
||||
|
||||
if _, err := s.db.Queries.UpsertDevice(ctx, database.UpsertDeviceParams{
|
||||
ID: request.Body.DeviceId,
|
||||
UserID: auth.UserName,
|
||||
DeviceName: request.Body.DeviceName,
|
||||
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Error("UpsertDevice DB Error:", err)
|
||||
return UpdateProgress500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||
}
|
||||
|
||||
if _, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||
ID: request.Body.DocumentId,
|
||||
}); err != nil {
|
||||
log.Error("UpsertDocument DB Error:", err)
|
||||
return UpdateProgress500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||
}
|
||||
|
||||
progress, err := s.db.Queries.UpdateProgress(ctx, database.UpdateProgressParams{
|
||||
Percentage: request.Body.Percentage,
|
||||
DocumentID: request.Body.DocumentId,
|
||||
DeviceID: request.Body.DeviceId,
|
||||
UserID: auth.UserName,
|
||||
Progress: request.Body.Progress,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("UpdateProgress DB Error:", err)
|
||||
return UpdateProgress400JSONResponse{Code: 400, Message: "Invalid request"}, nil
|
||||
}
|
||||
|
||||
response := UpdateProgressResponse{
|
||||
DocumentId: progress.DocumentID,
|
||||
Timestamp: parseTime(progress.CreatedAt),
|
||||
}
|
||||
|
||||
return UpdateProgress200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
|
||||
76
frontend/AGENTS.md
Normal file
76
frontend/AGENTS.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# AnthoLume Frontend Agent Guide
|
||||
|
||||
Read this file for work in `frontend/`.
|
||||
Also follow the repository root guide at `../AGENTS.md`.
|
||||
|
||||
## 1) Stack
|
||||
|
||||
- Package manager: `bun`
|
||||
- Framework: React + Vite
|
||||
- Data fetching: React Query
|
||||
- API generation: Orval
|
||||
- Linting: ESLint + Tailwind plugin
|
||||
- Formatting: Prettier
|
||||
|
||||
## 2) Conventions
|
||||
|
||||
- Use local icon components from `src/icons/`.
|
||||
- Do not add external icon libraries.
|
||||
- Prefer generated types from `src/generated/model/` over `any`.
|
||||
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
|
||||
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
|
||||
- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
|
||||
- Use theme tokens from `tailwind.config.js` / `src/index.css` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs.
|
||||
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
|
||||
|
||||
## 3) Generated API client
|
||||
|
||||
- Do not edit `src/generated/**` directly.
|
||||
- Edit `../api/v1/openapi.yaml` and regenerate instead.
|
||||
- Regenerate with: `bun run generate:api`
|
||||
|
||||
### Important behavior
|
||||
|
||||
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
||||
- Do not assume non-2xx responses throw.
|
||||
- Check `response.status` and response shape before treating a request as successful.
|
||||
|
||||
## 4) Auth / Query State
|
||||
|
||||
- When changing auth flows, account for React Query cache state.
|
||||
- Pay special attention to `/api/v1/auth/me`.
|
||||
- A local auth state update may not be enough if cached query data still reflects a previous auth state.
|
||||
|
||||
## 5) Commands
|
||||
|
||||
- Lint: `bun run lint`
|
||||
- Typecheck: `bun run typecheck`
|
||||
- Lint fix: `bun run lint:fix`
|
||||
- Format check: `bun run format`
|
||||
- Format fix: `bun run format:fix`
|
||||
- Build: `bun run build`
|
||||
- Generate API client: `bun run generate:api`
|
||||
|
||||
## 6) Validation Notes
|
||||
|
||||
- ESLint ignores `src/generated/**`.
|
||||
- Frontend unit tests use Vitest and live alongside source as `src/**/*.test.ts(x)`.
|
||||
- Read `TESTING_STRATEGY.md` before adding or expanding frontend tests.
|
||||
- Prefer tests for meaningful app behavior, branching logic, side effects, and user-visible outcomes.
|
||||
- Avoid low-value tests that mainly assert exact styling classes, duplicate existing coverage, or re-test framework/library behavior.
|
||||
- `bun run lint` includes test files but does not typecheck.
|
||||
- Use `bun run typecheck` to run TypeScript validation for app code and colocated tests without a full production build.
|
||||
- Run frontend tests with `bun run test`.
|
||||
- `bun run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build.
|
||||
- When possible, validate changed files directly before escalating to full-project fixes.
|
||||
|
||||
## 7) Updating This File
|
||||
|
||||
After completing a frontend task, update this file if you learned something general that would help future frontend agents.
|
||||
|
||||
Rules for updates:
|
||||
|
||||
- Add only frontend-wide guidance.
|
||||
- Do not record one-off task history.
|
||||
- Keep updates concise and action-oriented.
|
||||
- Prefer notes that prevent repeated mistakes.
|
||||
73
frontend/TESTING_STRATEGY.md
Normal file
73
frontend/TESTING_STRATEGY.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Frontend Testing Strategy
|
||||
|
||||
This project prefers meaningful frontend tests over high test counts.
|
||||
|
||||
## What we want to test
|
||||
|
||||
Prioritize tests for app-owned behavior such as:
|
||||
|
||||
- user-visible page and component behavior
|
||||
- auth and routing behavior
|
||||
- branching logic and business rules
|
||||
- data normalization and error handling
|
||||
- timing behavior with real app logic
|
||||
- side effects that could regress, such as token handling or redirects
|
||||
- algorithmic or formatting logic that defines product behavior
|
||||
|
||||
Good examples in this repo:
|
||||
|
||||
- login and registration flows
|
||||
- protected-route behavior
|
||||
- auth interceptor token injection and cleanup
|
||||
- error message extraction
|
||||
- debounce timing
|
||||
- human-readable formatting logic
|
||||
- graph/algorithm output where exact parity matters
|
||||
|
||||
## What we usually do not want to test
|
||||
|
||||
Avoid tests that mostly prove:
|
||||
|
||||
- the language/runtime works
|
||||
- React forwards basic props correctly
|
||||
- a third-party library behaves as documented
|
||||
- exact Tailwind class strings with no product meaning
|
||||
- implementation details not observable in behavior
|
||||
- duplicated examples that re-assert the same logic
|
||||
|
||||
In other words, do not add tests equivalent to checking that JavaScript can compute `1 + 1`.
|
||||
|
||||
## Preferred test style
|
||||
|
||||
- Prefer behavior-focused assertions over implementation-detail assertions.
|
||||
- Prefer user-visible outcomes over internal state inspection.
|
||||
- Mock at module boundaries when needed.
|
||||
- Keep test setup small and local.
|
||||
- Use exact-output assertions only when the output itself is the contract.
|
||||
|
||||
## When exact assertions are appropriate
|
||||
|
||||
Exact assertions are appropriate when they protect a real contract, for example:
|
||||
|
||||
- a formatter's exact human-readable output
|
||||
- auth decision outcomes for a given API response shape
|
||||
- exact algorithm output that must remain stable
|
||||
|
||||
Exact assertions are usually not appropriate for:
|
||||
|
||||
- incidental class names
|
||||
- framework internals
|
||||
- non-observable React keys
|
||||
|
||||
## Cleanup rule of thumb
|
||||
|
||||
Keep tests that would catch meaningful regressions in product behavior.
|
||||
Trim or remove tests that are brittle, duplicated, or mostly validate tooling rather than app logic.
|
||||
|
||||
## Validation
|
||||
|
||||
For frontend test work, validate with:
|
||||
|
||||
- `cd frontend && bun run lint`
|
||||
- `cd frontend && bun run typecheck`
|
||||
- `cd frontend && bun run test`
|
||||
@@ -9,7 +9,8 @@
|
||||
"ajv": "^8.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"orval": "8.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -18,6 +19,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||
@@ -30,17 +34,27 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.4", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7" } }, "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
@@ -73,14 +87,30 @@
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.1", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
@@ -151,6 +181,8 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
|
||||
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
@@ -277,10 +309,22 @@
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
@@ -289,12 +333,18 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/localforage": ["@types/localforage@0.0.34", "", { "dependencies": { "localforage": "*" } }, "sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
@@ -325,6 +375,22 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.7.13", "", {}, "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -349,6 +415,8 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
|
||||
"array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
|
||||
@@ -363,6 +431,8 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
@@ -377,6 +447,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
@@ -397,6 +469,8 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -419,12 +493,24 @@
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
|
||||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
|
||||
@@ -433,6 +519,8 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@@ -441,19 +529,25 @@
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"epubjs": ["epubjs@0.3.93", "", { "dependencies": { "@types/localforage": "0.0.34", "@xmldom/xmldom": "^0.7.5", "core-js": "^3.18.3", "event-emitter": "^0.3.5", "jszip": "^3.7.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "marks-pane": "^1.0.9", "path-webpack": "0.0.3" } }, "sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
|
||||
|
||||
@@ -463,6 +557,8 @@
|
||||
|
||||
"es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
@@ -471,6 +567,12 @@
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
|
||||
|
||||
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
|
||||
|
||||
"es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
@@ -493,6 +595,8 @@
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
@@ -501,10 +605,18 @@
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
@@ -589,14 +701,22 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
@@ -637,6 +757,8 @@
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
|
||||
@@ -671,6 +793,8 @@
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsdom": ["jsdom@29.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
@@ -687,34 +811,48 @@
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||
|
||||
"localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
|
||||
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
||||
|
||||
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
|
||||
"marks-pane": ["marks-pane@1.0.9", "", {}, "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
@@ -725,6 +863,8 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -735,12 +875,16 @@
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
|
||||
|
||||
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"nosleep.js": ["nosleep.js@0.12.0", "", {}, "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -759,6 +903,8 @@
|
||||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"orval": ["orval@8.5.3", "", { "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@orval/angular": "8.5.3", "@orval/axios": "8.5.3", "@orval/core": "8.5.3", "@orval/fetch": "8.5.3", "@orval/hono": "8.5.3", "@orval/mcp": "8.5.3", "@orval/mock": "8.5.3", "@orval/query": "8.5.3", "@orval/solid-start": "8.5.3", "@orval/swr": "8.5.3", "@orval/zod": "8.5.3", "@scalar/json-magic": "^0.11.5", "@scalar/openapi-parser": "^0.24.13", "@scalar/openapi-types": "0.5.3", "chokidar": "^5.0.0", "commander": "^14.0.2", "enquirer": "^2.4.1", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "js-yaml": "4.1.1", "remeda": "^2.33.6", "string-argv": "^0.3.2", "tsconfck": "^3.1.6", "typedoc": "^0.28.17", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/orval.mjs" }, "sha512-+8Es2ZR3tPthzAL27X1a9AlboqTQ/w9U/PhMkp4vsLA9OvdkpXr+9f8lCfJUV/wtdX+lXBDQ4imx42Em943JSg=="],
|
||||
@@ -769,16 +915,22 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-webpack": ["path-webpack@0.0.3", "", {}, "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
@@ -811,8 +963,12 @@
|
||||
|
||||
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
@@ -837,8 +993,12 @@
|
||||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
@@ -859,10 +1019,14 @@
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -875,6 +1039,8 @@
|
||||
|
||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
@@ -887,12 +1053,18 @@
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||
@@ -907,10 +1079,14 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
@@ -919,6 +1095,8 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
@@ -929,16 +1107,32 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.27", "", { "dependencies": { "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.27", "", {}, "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
|
||||
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
@@ -961,6 +1155,8 @@
|
||||
|
||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||
|
||||
"undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
@@ -975,6 +1171,16 @@
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "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.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "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.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
@@ -985,8 +1191,14 @@
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||
@@ -997,6 +1209,8 @@
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
@@ -1009,6 +1223,10 @@
|
||||
|
||||
"@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.5.4", "", { "dependencies": { "zod": "^4.3.5" } }, "sha512-2pEbhprh8lLGDfUI6mNm9EV104pjb3+aJsXrFaqfgOSre7r6NlgM5HcSbsLjzDAnTikjJhJ3IMal1Rz8WVwiOw=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -1027,6 +1245,10 @@
|
||||
|
||||
"globby/unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="],
|
||||
|
||||
"localforage/lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
||||
|
||||
"markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
@@ -1037,6 +1259,12 @@
|
||||
|
||||
"postcss-import/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
171
frontend/dist/assets/index-C7Wct-hD.js
vendored
171
frontend/dist/assets/index-C7Wct-hD.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-Co--bktJ.css
vendored
1
frontend/dist/assets/index-Co--bktJ.css
vendored
File diff suppressed because one or more lines are too long
32
frontend/dist/index.html
vendored
32
frontend/dist/index.html
vendored
@@ -1,32 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="#F3F4F6"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="#1F2937"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<title>AnthoLume</title>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<script type="module" crossorigin src="/assets/index-C7Wct-hD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Co--bktJ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,19 +5,23 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate:api": "orval",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"format": "prettier --check src",
|
||||
"format:fix": "prettier --write src"
|
||||
"format:fix": "prettier --write src",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.16",
|
||||
"ajv": "^8.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"epubjs": "^0.3.93",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"orval": "8.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -26,6 +30,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||
@@ -38,10 +45,12 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5"
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import AdminImportPage from './pages/AdminImportPage';
|
||||
import AdminImportResultsPage from './pages/AdminImportResultsPage';
|
||||
import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import AdminLogsPage from './pages/AdminLogsPage';
|
||||
import ReaderPage from './pages/ReaderPage';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
|
||||
export function Routes() {
|
||||
@@ -118,6 +119,14 @@ export function Routes() {
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="/reader/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ReaderPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
</ReactRoutes>
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
useGetMe,
|
||||
useRegister,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: { username: string; is_admin: boolean } | null;
|
||||
isCheckingAuth: boolean;
|
||||
}
|
||||
import {
|
||||
type AuthState,
|
||||
getAuthenticatedAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
} from './authHelpers';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (_username: string, _password: string) => Promise<void>;
|
||||
@@ -23,12 +24,14 @@ interface AuthContextType extends AuthState {
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
const initialAuthState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
@@ -40,26 +43,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setAuthState(prev => {
|
||||
if (meLoading) {
|
||||
return { ...prev, isCheckingAuth: true };
|
||||
} else if (meData?.data && meData.status === 200) {
|
||||
const userData = 'username' in meData.data ? meData.data : null;
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user: userData as { username: string; is_admin: boolean } | null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
} else if (meError || (meData && meData.status === 401)) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...prev, isCheckingAuth: false };
|
||||
});
|
||||
setAuthState(prev =>
|
||||
resolveAuthStateFromMe({
|
||||
meData,
|
||||
meError,
|
||||
meLoading,
|
||||
previousState: prev,
|
||||
})
|
||||
);
|
||||
}, [meData, meError, meLoading]);
|
||||
|
||||
const login = useCallback(
|
||||
@@ -72,29 +63,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 200 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
const user = validateAuthMutationResponse(response, 200);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
@@ -111,29 +91,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 201 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
const user = validateAuthMutationResponse(response, 201);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
},
|
||||
@@ -143,11 +112,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/login');
|
||||
},
|
||||
|
||||
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
vi.mock('./AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows a loading state while auth is being checked', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: true,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/private']}>
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to the login page', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/private']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/private"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children for authenticated users', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Secret')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,11 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Show loading while checking authentication status
|
||||
if (isCheckingAuth) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login with the current location saved
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
|
||||
157
frontend/src/auth/authHelpers.test.ts
Normal file
157
frontend/src/auth/authHelpers.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCheckingAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
normalizeAuthenticatedUser,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
type AuthState,
|
||||
} from './authHelpers';
|
||||
|
||||
const previousState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
|
||||
describe('authHelpers', () => {
|
||||
it('normalizes a valid authenticated user payload', () => {
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: true })).toEqual({
|
||||
username: 'evan',
|
||||
is_admin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid authenticated user payloads', () => {
|
||||
expect(normalizeAuthenticatedUser(null)).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan' })).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 123, is_admin: true })).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: 'yes' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a checking state while preserving previous auth information', () => {
|
||||
expect(
|
||||
getCheckingAuthState({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: false,
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state from a successful /auth/me response', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 401,
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
meError: new Error('failed'),
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
});
|
||||
|
||||
it('keeps checking state while /auth/me is still loading', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
meError: undefined,
|
||||
meLoading: true,
|
||||
previousState: {
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
isCheckingAuth: false,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the previous state with checking disabled when there is no decisive me result', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 204,
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState: {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates auth mutation responses by expected status and payload shape', () => {
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toEqual({ username: 'evan', is_admin: false });
|
||||
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 201,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 200,
|
||||
data: { username: 'evan' },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
98
frontend/src/auth/authHelpers.ts
Normal file
98
frontend/src/auth/authHelpers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: AuthUser | null;
|
||||
isCheckingAuth: boolean;
|
||||
}
|
||||
|
||||
interface ResponseLike {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export function getUnauthenticatedAuthState(): AuthState {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCheckingAuthState(previousState?: AuthState): AuthState {
|
||||
return {
|
||||
isAuthenticated: previousState?.isAuthenticated ?? false,
|
||||
user: previousState?.user ?? null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAuthenticatedUser(value: unknown): AuthUser | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('username' in value) || typeof value.username !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('is_admin' in value) || typeof value.is_admin !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username: value.username,
|
||||
is_admin: value.is_admin,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAuthStateFromMe(params: {
|
||||
meData?: ResponseLike;
|
||||
meError?: unknown;
|
||||
meLoading: boolean;
|
||||
previousState: AuthState;
|
||||
}): AuthState {
|
||||
const { meData, meError, meLoading, previousState } = params;
|
||||
|
||||
if (meLoading) {
|
||||
return getCheckingAuthState(previousState);
|
||||
}
|
||||
|
||||
if (meData?.status === 200) {
|
||||
const user = normalizeAuthenticatedUser(meData.data);
|
||||
if (user) {
|
||||
return getAuthenticatedAuthState(user);
|
||||
}
|
||||
}
|
||||
|
||||
if (meError || meData?.status === 401) {
|
||||
return getUnauthenticatedAuthState();
|
||||
}
|
||||
|
||||
return {
|
||||
...previousState,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAuthMutationResponse(
|
||||
response: ResponseLike,
|
||||
expectedStatus: number
|
||||
): AuthUser | null {
|
||||
if (response.status !== expectedStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAuthenticatedUser(response.data);
|
||||
}
|
||||
11
frontend/src/auth/authInterceptor.test.ts
Normal file
11
frontend/src/auth/authInterceptor.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { setupAuthInterceptors } from './authInterceptor';
|
||||
|
||||
describe('setupAuthInterceptors', () => {
|
||||
it('is a no-op when auth is handled by HttpOnly cookies', () => {
|
||||
const cleanup = setupAuthInterceptors();
|
||||
|
||||
expect(typeof cleanup).toBe('function');
|
||||
expect(() => cleanup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,3 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const TOKEN_KEY = 'antholume_token';
|
||||
|
||||
// Request interceptor to add auth token to requests
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
export function setupAuthInterceptors() {
|
||||
return () => {};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle auth errors
|
||||
axios.interceptors.response.use(
|
||||
response => {
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear token on auth failure
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
// Optionally redirect to login
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default axios;
|
||||
|
||||
@@ -11,13 +11,13 @@ type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { h
|
||||
|
||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||
const baseClass =
|
||||
'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white';
|
||||
'h-full w-full px-2 py-1 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white`;
|
||||
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100`;
|
||||
return `${baseClass} bg-primary-500 text-primary-foreground hover:bg-primary-700 disabled:hover:bg-primary-500`;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
|
||||
@@ -9,7 +9,7 @@ interface FieldProps {
|
||||
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
||||
return (
|
||||
<div className="relative rounded">
|
||||
<div className="relative inline-flex gap-2 text-gray-500">{label}</div>
|
||||
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,6 @@ const adminSubItems: NavItem[] = [
|
||||
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
|
||||
];
|
||||
|
||||
// Helper function to check if pathname has a prefix
|
||||
function hasPrefix(path: string, prefix: string): boolean {
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
@@ -37,10 +36,9 @@ export default function HamburgerMenu() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isAdmin = user?.is_admin ?? false;
|
||||
|
||||
// Fetch server info for version
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity, // Info doesn't change frequently
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
const version =
|
||||
@@ -50,7 +48,6 @@ export default function HamburgerMenu() {
|
||||
|
||||
return (
|
||||
<div className="relative z-40 ml-6 flex flex-col">
|
||||
{/* Checkbox input for state management */}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
|
||||
@@ -59,9 +56,8 @@ export default function HamburgerMenu() {
|
||||
onChange={e => setIsOpen(e.target.checked)}
|
||||
/>
|
||||
|
||||
{/* Hamburger icon lines with CSS animations - hidden on desktop */}
|
||||
<span
|
||||
className="z-40 mt-0.5 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
|
||||
className="z-40 mt-0.5 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '5px 0px',
|
||||
transition:
|
||||
@@ -70,7 +66,7 @@ export default function HamburgerMenu() {
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '0% 100%',
|
||||
transition:
|
||||
@@ -80,7 +76,7 @@ export default function HamburgerMenu() {
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '0% 0%',
|
||||
transition:
|
||||
@@ -89,21 +85,17 @@ export default function HamburgerMenu() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navigation menu with slide animation */}
|
||||
<div
|
||||
id="menu"
|
||||
className="fixed -ml-6 h-full w-56 bg-white shadow-lg lg:w-48 dark:bg-gray-700"
|
||||
className="fixed -ml-6 h-full w-56 bg-surface shadow-lg lg:w-48"
|
||||
style={{
|
||||
top: 0,
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
transformOrigin: '0% 0%',
|
||||
// On desktop (lg), always show the menu via CSS class
|
||||
// On mobile, control via state
|
||||
transform: isOpen ? 'none' : 'translate(-100%, 0)',
|
||||
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
|
||||
}}
|
||||
>
|
||||
{/* Desktop override - always visible */}
|
||||
<style>{`
|
||||
@media (min-width: 1024px) {
|
||||
#menu {
|
||||
@@ -112,9 +104,7 @@ export default function HamburgerMenu() {
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-16 justify-end lg:justify-around">
|
||||
<p className="my-auto pr-8 text-right text-xl font-bold lg:pr-0 dark:text-white">
|
||||
AnthoLume
|
||||
</p>
|
||||
<p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">AnthoLume</p>
|
||||
</div>
|
||||
<nav>
|
||||
{navItems.map(item => (
|
||||
@@ -124,8 +114,8 @@ export default function HamburgerMenu() {
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||
location.pathname === item.path
|
||||
? 'border-purple-500 dark:text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
||||
? 'border-primary-500 text-content'
|
||||
: 'border-transparent text-content-subtle hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
@@ -133,23 +123,21 @@ export default function HamburgerMenu() {
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Admin section - only visible for admins */}
|
||||
{isAdmin && (
|
||||
<div
|
||||
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||
hasPrefix(location.pathname, '/admin')
|
||||
? 'border-purple-500 dark:text-white'
|
||||
: 'border-transparent text-gray-400'
|
||||
? 'border-primary-500 text-content'
|
||||
: 'border-transparent text-content-subtle'
|
||||
}`}
|
||||
>
|
||||
{/* Admin header - always shown */}
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex w-full justify-start ${
|
||||
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
|
||||
? 'dark:text-white'
|
||||
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
||||
? 'text-content'
|
||||
: 'text-content-subtle hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
@@ -165,8 +153,8 @@ export default function HamburgerMenu() {
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex w-full justify-start ${
|
||||
location.pathname === item.path
|
||||
? 'dark:text-white'
|
||||
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
||||
? 'text-content'
|
||||
: 'text-content-subtle hover:text-content'
|
||||
}`}
|
||||
style={{ paddingLeft: '1.75em' }}
|
||||
>
|
||||
@@ -179,7 +167,7 @@ export default function HamburgerMenu() {
|
||||
)}
|
||||
</nav>
|
||||
<a
|
||||
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-black dark:text-white"
|
||||
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-content"
|
||||
target="_blank"
|
||||
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
||||
rel="noreferrer"
|
||||
|
||||
@@ -3,11 +3,16 @@ import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import HamburgerMenu from './HamburgerMenu';
|
||||
|
||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
||||
const { themeMode, setThemeMode } = useTheme();
|
||||
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
||||
const fetchedUser =
|
||||
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
|
||||
@@ -20,7 +25,6 @@ export default function Layout() {
|
||||
setIsUserDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
@@ -34,7 +38,6 @@ export default function Layout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get current page title
|
||||
const navItems = [
|
||||
{ path: '/admin/import-results', title: 'Admin - Import' },
|
||||
{ path: '/admin/import', title: 'Admin - Import' },
|
||||
@@ -57,43 +60,62 @@ export default function Layout() {
|
||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||
}, [currentPageTitle]);
|
||||
|
||||
// Show loading while checking authentication status
|
||||
if (isCheckingAuth) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800">
|
||||
{/* Header */}
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="flex h-16 w-full items-center justify-between">
|
||||
{/* Mobile Navigation Button with CSS animations */}
|
||||
<HamburgerMenu />
|
||||
|
||||
{/* Header Title */}
|
||||
<h1 className="whitespace-nowrap px-6 text-xl font-bold lg:ml-44 dark:text-white">
|
||||
<h1 className="whitespace-nowrap px-6 text-xl font-bold text-content lg:ml-44">
|
||||
{currentPageTitle}
|
||||
</h1>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div
|
||||
className="relative flex w-full items-center justify-end space-x-4 p-4"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||
className="relative block text-gray-800 dark:text-gray-200"
|
||||
className="relative block text-content"
|
||||
>
|
||||
<UserIcon size={20} />
|
||||
</button>
|
||||
|
||||
{isUserDropdownOpen && (
|
||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
||||
<div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-700 dark:shadow-gray-800">
|
||||
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
||||
<div
|
||||
className="border-b border-border px-4 py-3"
|
||||
role="group"
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
||||
Theme
|
||||
</p>
|
||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
||||
{themeModes.map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode)}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${
|
||||
themeMode === mode
|
||||
? 'bg-content text-content-inverse'
|
||||
: 'text-content-muted hover:bg-surface hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="py-1"
|
||||
role="menu"
|
||||
@@ -103,7 +125,7 @@ export default function Layout() {
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setIsUserDropdownOpen(false)}
|
||||
className="block px-4 py-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
className="block px-4 py-2 text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
@@ -112,7 +134,7 @@ export default function Layout() {
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
className="block w-full px-4 py-2 text-left text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
@@ -126,11 +148,11 @@ export default function Layout() {
|
||||
|
||||
<button
|
||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||
className="flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white"
|
||||
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
|
||||
>
|
||||
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
|
||||
<span
|
||||
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
|
||||
className="text-content transition-transform duration-200"
|
||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<DropdownIcon size={20} />
|
||||
@@ -139,7 +161,6 @@ export default function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
className="relative overflow-hidden"
|
||||
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}
|
||||
|
||||
21
frontend/src/components/LoadingState.tsx
Normal file
21
frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { LoadingIcon } from '../icons';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
message = 'Loading...',
|
||||
className = '',
|
||||
iconSize = 24,
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-3 text-content-muted', className)}>
|
||||
<LoadingIcon size={iconSize} className="text-primary-500" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSVGGraphData } from './ReadingHistoryGraph';
|
||||
|
||||
// Test data matching Go test exactly
|
||||
// Intentionally exact fixture data for algorithm parity coverage
|
||||
const testInput = [
|
||||
{ date: '2024-01-01', minutes_read: 10 },
|
||||
{ date: '2024-01-02', minutes_read: 90 },
|
||||
@@ -22,7 +23,7 @@ describe('ReadingHistoryGraph', () => {
|
||||
it('should match exactly', () => {
|
||||
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
|
||||
|
||||
// Expected values from Go test
|
||||
// Expected exact algorithm output
|
||||
const expectedBezierPath =
|
||||
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
|
||||
const expectedBezierFill = 'L 500,98 L 50,98 Z';
|
||||
@@ -36,13 +37,13 @@ describe('ReadingHistoryGraph', () => {
|
||||
expect(svgHeight).toBe(expectedHeight);
|
||||
expect(result.Offset).toBe(expectedOffset);
|
||||
|
||||
// Verify line points are integers like Go
|
||||
// Verify line points are integer pixel values
|
||||
result.LinePoints.forEach((p, _i) => {
|
||||
expect(Number.isInteger(p.x)).toBe(true);
|
||||
expect(Number.isInteger(p.y)).toBe(true);
|
||||
});
|
||||
|
||||
// Expected line points from Go calculation:
|
||||
// Expected line points from the current algorithm:
|
||||
// idx 0: itemSize=5, itemY=95, lineX=50
|
||||
// idx 1: itemSize=45, itemY=55, lineX=100
|
||||
// idx 2: itemSize=25, itemY=75, lineX=150
|
||||
|
||||
@@ -9,9 +9,6 @@ export interface SVGPoint {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates bezier control points for smooth curves
|
||||
*/
|
||||
function getSVGBezierOpposedLine(
|
||||
pointA: SVGPoint,
|
||||
pointB: SVGPoint
|
||||
@@ -19,7 +16,6 @@ function getSVGBezierOpposedLine(
|
||||
const lengthX = pointB.x - pointA.x;
|
||||
const lengthY = pointB.y - pointA.y;
|
||||
|
||||
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
|
||||
return {
|
||||
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
|
||||
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
|
||||
@@ -32,7 +28,6 @@ function getBezierControlPoint(
|
||||
nextPoint: SVGPoint | null,
|
||||
isReverse: boolean
|
||||
): SVGPoint {
|
||||
// First / Last Point
|
||||
let pPrev = prevPoint;
|
||||
let pNext = nextPoint;
|
||||
if (!pPrev) {
|
||||
@@ -42,57 +37,49 @@ function getBezierControlPoint(
|
||||
pNext = currentPoint;
|
||||
}
|
||||
|
||||
// Modifiers
|
||||
const smoothingRatio: number = 0.2;
|
||||
const directionModifier: number = isReverse ? Math.PI : 0;
|
||||
const smoothingRatio = 0.2;
|
||||
const directionModifier = isReverse ? Math.PI : 0;
|
||||
|
||||
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
|
||||
const lineAngle: number = opposingLine.Angle + directionModifier;
|
||||
const lineLength: number = opposingLine.Length * smoothingRatio;
|
||||
const lineAngle = opposingLine.Angle + directionModifier;
|
||||
const lineLength = opposingLine.Length * smoothingRatio;
|
||||
|
||||
// Calculate Control Point - Go converts everything to int
|
||||
// Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
|
||||
return {
|
||||
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
|
||||
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the bezier path for the graph
|
||||
*/
|
||||
function getSVGBezierPath(points: SVGPoint[]): string {
|
||||
if (points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let bezierSVGPath: string = '';
|
||||
let bezierSVGPath = '';
|
||||
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const point = points[index];
|
||||
if (!point) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
bezierSVGPath += `M ${point.x},${point.y}`;
|
||||
} else {
|
||||
const pointPlusOne = points[index + 1];
|
||||
const pointMinusOne = points[index - 1];
|
||||
const pointMinusTwo: SVGPoint | null = index - 2 >= 0 ? points[index - 2] : null;
|
||||
|
||||
const startControlPoint: SVGPoint = getBezierControlPoint(
|
||||
pointMinusOne,
|
||||
pointMinusTwo,
|
||||
point,
|
||||
false
|
||||
);
|
||||
const endControlPoint: SVGPoint = getBezierControlPoint(
|
||||
point,
|
||||
pointMinusOne,
|
||||
pointPlusOne || point,
|
||||
true
|
||||
);
|
||||
|
||||
// Go converts all coordinates to int
|
||||
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointMinusOne = points[index - 1];
|
||||
if (!pointMinusOne) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointPlusOne = points[index + 1] ?? point;
|
||||
const pointMinusTwo = index - 2 >= 0 ? (points[index - 2] ?? null) : null;
|
||||
|
||||
const startControlPoint = getBezierControlPoint(pointMinusOne, pointMinusTwo, point, false);
|
||||
const endControlPoint = getBezierControlPoint(point, pointMinusOne, pointPlusOne, true);
|
||||
|
||||
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
||||
}
|
||||
|
||||
return bezierSVGPath;
|
||||
@@ -105,49 +92,39 @@ export interface SVGGraphData {
|
||||
Offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SVG Graph Data
|
||||
*/
|
||||
export function getSVGGraphData(
|
||||
inputData: GraphDataPoint[],
|
||||
svgWidth: number,
|
||||
svgHeight: number
|
||||
): SVGGraphData {
|
||||
// Derive Height
|
||||
let maxHeight: number = 0;
|
||||
let maxHeight = 0;
|
||||
for (const item of inputData) {
|
||||
if (item.minutes_read > maxHeight) {
|
||||
maxHeight = item.minutes_read;
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical Graph Real Estate
|
||||
const sizePercentage: number = 0.5;
|
||||
const sizePercentage = 0.5;
|
||||
const sizeRatio = maxHeight > 0 ? (svgHeight * sizePercentage) / maxHeight : 0;
|
||||
const blockOffset = inputData.length > 0 ? Math.floor(svgWidth / inputData.length) : 0;
|
||||
|
||||
// Scale Ratio -> Desired Height
|
||||
const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
|
||||
|
||||
// Point Block Offset
|
||||
const blockOffset: number = Math.floor(svgWidth / inputData.length);
|
||||
|
||||
// Line & Bar Points
|
||||
const linePoints: SVGPoint[] = [];
|
||||
|
||||
// Bezier Fill Coordinates (Max X, Min X, Max Y)
|
||||
let maxBX: number = 0;
|
||||
let maxBY: number = 0;
|
||||
let minBX: number = 0;
|
||||
let maxBX = 0;
|
||||
let maxBY = 0;
|
||||
let minBX = 0;
|
||||
|
||||
for (let idx = 0; idx < inputData.length; idx++) {
|
||||
// Go uses int conversion
|
||||
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
|
||||
const item = inputData[idx];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemSize = Math.floor(item.minutes_read * sizeRatio);
|
||||
const itemY = svgHeight - itemSize;
|
||||
const lineX = (idx + 1) * blockOffset;
|
||||
|
||||
linePoints.push({
|
||||
x: lineX,
|
||||
y: itemY,
|
||||
});
|
||||
linePoints.push({ x: lineX, y: itemY });
|
||||
|
||||
if (lineX > maxBX) {
|
||||
maxBX = lineX;
|
||||
@@ -162,7 +139,6 @@ export function getSVGGraphData(
|
||||
}
|
||||
}
|
||||
|
||||
// Return Data
|
||||
return {
|
||||
LinePoints: linePoints,
|
||||
BezierPath: getSVGBezierPath(linePoints),
|
||||
@@ -171,50 +147,33 @@ export function getSVGGraphData(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to YYYY-MM-DD format (ISO-like)
|
||||
* Note: The date string from the API is already in YYYY-MM-DD format,
|
||||
* but since JavaScript Date parsing can add timezone offsets, we use UTC
|
||||
* methods to ensure we get the correct date.
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
// Use UTC methods to avoid timezone offset issues
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadingHistoryGraph component
|
||||
*
|
||||
* Displays a bezier curve graph of daily reading totals with hover tooltips.
|
||||
* Exact copy of Go template implementation.
|
||||
*/
|
||||
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
||||
const svgWidth = 800;
|
||||
const svgHeight = 70;
|
||||
|
||||
if (!data || data.length < 2) {
|
||||
return (
|
||||
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
||||
<p className="text-gray-400 dark:text-gray-300">No data available</p>
|
||||
<div className="relative flex h-24 items-center justify-center bg-surface-muted">
|
||||
<p className="text-content-subtle">No data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
BezierPath,
|
||||
BezierFill,
|
||||
LinePoints: _linePoints,
|
||||
} = getSVGGraphData(data, svgWidth, svgHeight);
|
||||
const { BezierPath, BezierFill } = getSVGGraphData(data, svgWidth, svgHeight);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
||||
<path fill="#316BBE" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
||||
<path fill="none" stroke="#316BBE" d={BezierPath} />
|
||||
<path fill="rgb(var(--secondary-600))" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
||||
<path fill="none" stroke="rgb(var(--secondary-600))" d={BezierPath} />
|
||||
</svg>
|
||||
<div
|
||||
className="absolute top-0 flex size-full"
|
||||
@@ -227,7 +186,6 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
||||
{data.map((point, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick
|
||||
className="w-full opacity-0 hover:opacity-100"
|
||||
style={{
|
||||
background:
|
||||
@@ -235,11 +193,10 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute top-3 flex flex-col items-center rounded p-2 text-xs dark:text-white"
|
||||
className="pointer-events-none absolute top-3 flex flex-col items-center rounded bg-surface/80 p-2 text-xs text-content"
|
||||
style={{
|
||||
transform: 'translateX(-50%)',
|
||||
left: '50%',
|
||||
backgroundColor: 'rgba(128, 128, 128, 0.2)',
|
||||
}}
|
||||
>
|
||||
<span>{formatDate(point.date)}</span>
|
||||
|
||||
@@ -15,11 +15,11 @@ export function Skeleton({
|
||||
height,
|
||||
animation = 'pulse',
|
||||
}: SkeletonProps) {
|
||||
const baseClasses = 'bg-gray-200 dark:bg-gray-600';
|
||||
const baseClasses = 'bg-surface-strong';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'rounded',
|
||||
text: 'rounded-md h-4',
|
||||
text: 'h-4 rounded-md',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-none',
|
||||
};
|
||||
@@ -97,12 +97,7 @@ export function SkeletonCard({
|
||||
textLines = 3,
|
||||
}: SkeletonCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn('rounded-lg border border-border bg-surface p-4', className)}>
|
||||
{showAvatar && (
|
||||
<div className="mb-4 flex items-start gap-4">
|
||||
<SkeletonAvatar />
|
||||
@@ -132,11 +127,11 @@ export function SkeletonTable({
|
||||
showHeader = true,
|
||||
}: SkeletonTableProps) {
|
||||
return (
|
||||
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
|
||||
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
|
||||
<table className="min-w-full">
|
||||
{showHeader && (
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-600">
|
||||
<tr className="border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<th key={i} className="p-3">
|
||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
||||
@@ -147,7 +142,7 @@ export function SkeletonTable({
|
||||
)}
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
|
||||
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<td key={colIndex} className="p-3">
|
||||
<Skeleton
|
||||
@@ -187,11 +182,11 @@ interface PageLoaderProps {
|
||||
|
||||
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center min-h-[400px] gap-4', className)}>
|
||||
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
|
||||
<div className="relative">
|
||||
<div className="size-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500 dark:border-gray-600" />
|
||||
<div className="size-12 animate-spin rounded-full border-4 border-surface-strong border-t-secondary-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{message}</p>
|
||||
<p className="text-sm font-medium text-content-muted">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,19 +198,18 @@ interface InlineLoaderProps {
|
||||
|
||||
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
|
||||
const sizeMap = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-6 h-6 border-3',
|
||||
lg: 'w-8 h-8 border-4',
|
||||
sm: 'h-4 w-4 border-2',
|
||||
md: 'h-6 w-6 border-[3px]',
|
||||
lg: 'h-8 w-8 border-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
<div
|
||||
className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-blue-500 dark:border-gray-600`}
|
||||
className={`${sizeMap[size]} animate-spin rounded-full border-surface-strong border-t-secondary-500`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export SkeletonTable for backward compatibility
|
||||
export { SkeletonTable as SkeletonTableExport };
|
||||
|
||||
56
frontend/src/components/Table.test.tsx
Normal file
56
frontend/src/components/Table.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Table, type Column } from './Table';
|
||||
|
||||
interface TestRow {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const columns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
},
|
||||
];
|
||||
|
||||
const data: TestRow[] = [
|
||||
{ id: 'user-1', name: 'Ada', role: 'Admin' },
|
||||
{ id: 'user-2', name: 'Grace', role: 'Reader' },
|
||||
];
|
||||
|
||||
describe('Table', () => {
|
||||
it('renders a skeleton table while loading', () => {
|
||||
const { container } = render(<Table columns={columns} data={[]} loading />);
|
||||
|
||||
expect(screen.queryByText('No Results')).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders the empty state message when there is no data', () => {
|
||||
render(<Table columns={columns} data={[]} emptyMessage="Nothing here" />);
|
||||
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses a custom render function for column output', () => {
|
||||
const customColumns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
|
||||
},
|
||||
];
|
||||
|
||||
render(<Table columns={customColumns} data={data} />);
|
||||
|
||||
expect(screen.getByText('1. ADA')).toBeInTheDocument();
|
||||
expect(screen.getByText('2. GRACE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -2,14 +2,14 @@ import React from 'react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export interface Column<T extends Record<string, unknown>> {
|
||||
export interface Column<T extends object> {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T extends Record<string, unknown>> {
|
||||
export interface TableProps<T extends object> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
@@ -17,7 +17,6 @@ export interface TableProps<T extends Record<string, unknown>> {
|
||||
rowKey?: keyof T | ((row: T) => string);
|
||||
}
|
||||
|
||||
// Skeleton table component for loading state
|
||||
function SkeletonTable({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
@@ -28,10 +27,10 @@ function SkeletonTable({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
|
||||
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-600">
|
||||
<tr className="border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<th key={i} className="p-3">
|
||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
||||
@@ -41,7 +40,7 @@ function SkeletonTable({
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
|
||||
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<td key={colIndex} className="p-3">
|
||||
<Skeleton
|
||||
@@ -58,19 +57,19 @@ function SkeletonTable({
|
||||
);
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, unknown>>({
|
||||
export function Table<T extends object>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
emptyMessage = 'No Results',
|
||||
rowKey,
|
||||
}: TableProps<T>) {
|
||||
const getRowKey = (_row: T, index: number): string => {
|
||||
const getRowKey = (row: T, index: number): string => {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(_row);
|
||||
return rowKey(row);
|
||||
}
|
||||
if (rowKey) {
|
||||
return String(_row[rowKey] ?? index);
|
||||
return String(row[rowKey] ?? index);
|
||||
}
|
||||
return `row-${index}`;
|
||||
};
|
||||
@@ -82,13 +81,13 @@ export function Table<T extends Record<string, unknown>>({
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-white dark:bg-gray-700">
|
||||
<table className="min-w-full bg-surface">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-600">
|
||||
<tr className="border-b border-border">
|
||||
{columns.map(column => (
|
||||
<th
|
||||
key={String(column.key)}
|
||||
className={`p-3 text-left text-gray-500 dark:text-white ${column.className || ''}`}
|
||||
className={`p-3 text-left text-content-muted ${column.className || ''}`}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
@@ -98,22 +97,21 @@ export function Table<T extends Record<string, unknown>>({
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="p-3 text-center text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<td colSpan={columns.length} className="p-3 text-center text-content-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<tr key={getRowKey(row, index)} className="border-b dark:border-gray-600">
|
||||
<tr key={getRowKey(row, index)} className="border-b border-border">
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={`${getRowKey(row, index)}-${String(column.key)}`}
|
||||
className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`}
|
||||
className={`p-3 text-content ${column.className || ''}`}
|
||||
>
|
||||
{column.render ? column.render(row[column.key], row, index) : row[column.key]}
|
||||
{column.render
|
||||
? column.render(row[column.key], row, index)
|
||||
: (row[column.key] as React.ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -13,24 +13,24 @@ export interface ToastProps {
|
||||
|
||||
const getToastStyles = (_type: ToastType) => {
|
||||
const baseStyles =
|
||||
'flex items-center gap-3 p-4 rounded-lg shadow-lg border-l-4 transition-all duration-300';
|
||||
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
|
||||
|
||||
const typeStyles = {
|
||||
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-500 dark:border-yellow-400',
|
||||
error: 'bg-red-50 dark:bg-red-900/30 border-red-500 dark:border-red-400',
|
||||
info: 'border-secondary-500 bg-secondary-100',
|
||||
warning: 'border-yellow-500 bg-yellow-100',
|
||||
error: 'border-red-500 bg-red-100',
|
||||
};
|
||||
|
||||
const iconStyles = {
|
||||
info: 'text-blue-600 dark:text-blue-400',
|
||||
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||
error: 'text-red-600 dark:text-red-400',
|
||||
info: 'text-secondary-700',
|
||||
warning: 'text-yellow-700',
|
||||
error: 'text-red-700',
|
||||
};
|
||||
|
||||
const textStyles = {
|
||||
info: 'text-blue-800 dark:text-blue-200',
|
||||
warning: 'text-yellow-800 dark:text-yellow-200',
|
||||
error: 'text-red-800 dark:text-red-200',
|
||||
info: 'text-secondary-900',
|
||||
warning: 'text-yellow-900',
|
||||
error: 'text-red-900',
|
||||
};
|
||||
|
||||
return { baseStyles, typeStyles, iconStyles, textStyles };
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
PageLoader,
|
||||
InlineLoader,
|
||||
} from './Skeleton';
|
||||
export { LoadingState } from './LoadingState';
|
||||
|
||||
// Field components
|
||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
|
||||
import type {
|
||||
ActivityResponse,
|
||||
CreateActivityRequest,
|
||||
CreateActivityResponse,
|
||||
CreateDocumentBody,
|
||||
DirectoryListResponse,
|
||||
DocumentResponse,
|
||||
@@ -55,6 +57,8 @@ import type {
|
||||
SearchResponse,
|
||||
SettingsResponse,
|
||||
StreaksResponse,
|
||||
UpdateProgressRequest,
|
||||
UpdateProgressResponse,
|
||||
UpdateSettingsRequest,
|
||||
UpdateUserBody,
|
||||
UploadDocumentCoverBody,
|
||||
@@ -1079,6 +1083,112 @@ export function useGetProgressList<TData = Awaited<ReturnType<typeof getProgress
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary Update document progress
|
||||
*/
|
||||
export type updateProgressResponse200 = {
|
||||
data: UpdateProgressResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type updateProgressResponse400 = {
|
||||
data: ErrorResponse
|
||||
status: 400
|
||||
}
|
||||
|
||||
export type updateProgressResponse401 = {
|
||||
data: ErrorResponse
|
||||
status: 401
|
||||
}
|
||||
|
||||
export type updateProgressResponse500 = {
|
||||
data: ErrorResponse
|
||||
status: 500
|
||||
}
|
||||
|
||||
export type updateProgressResponseSuccess = (updateProgressResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type updateProgressResponseError = (updateProgressResponse400 | updateProgressResponse401 | updateProgressResponse500) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type updateProgressResponse = (updateProgressResponseSuccess | updateProgressResponseError)
|
||||
|
||||
export const getUpdateProgressUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/v1/progress`
|
||||
}
|
||||
|
||||
export const updateProgress = async (updateProgressRequest: UpdateProgressRequest, options?: RequestInit): Promise<updateProgressResponse> => {
|
||||
|
||||
const res = await fetch(getUpdateProgressUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
updateProgressRequest,)
|
||||
}
|
||||
)
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: updateProgressResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as updateProgressResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getUpdateProgressMutationOptions = <TError = ErrorResponse,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateProgress>>, TError,{data: UpdateProgressRequest}, TContext>, fetch?: RequestInit}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateProgress>>, TError,{data: UpdateProgressRequest}, TContext> => {
|
||||
|
||||
const mutationKey = ['updateProgress'];
|
||||
const {mutation: mutationOptions, fetch: fetchOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, fetch: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateProgress>>, {data: UpdateProgressRequest}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return updateProgress(data,fetchOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UpdateProgressMutationResult = NonNullable<Awaited<ReturnType<typeof updateProgress>>>
|
||||
export type UpdateProgressMutationBody = UpdateProgressRequest
|
||||
export type UpdateProgressMutationError = ErrorResponse
|
||||
|
||||
/**
|
||||
* @summary Update document progress
|
||||
*/
|
||||
export const useUpdateProgress = <TError = ErrorResponse,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateProgress>>, TError,{data: UpdateProgressRequest}, TContext>, fetch?: RequestInit}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateProgress>>,
|
||||
TError,
|
||||
{data: UpdateProgressRequest},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateProgressMutationOptions(options), queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get document progress
|
||||
*/
|
||||
@@ -1349,6 +1459,112 @@ export function useGetActivity<TData = Awaited<ReturnType<typeof getActivity>>,
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary Create activity records
|
||||
*/
|
||||
export type createActivityResponse200 = {
|
||||
data: CreateActivityResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type createActivityResponse400 = {
|
||||
data: ErrorResponse
|
||||
status: 400
|
||||
}
|
||||
|
||||
export type createActivityResponse401 = {
|
||||
data: ErrorResponse
|
||||
status: 401
|
||||
}
|
||||
|
||||
export type createActivityResponse500 = {
|
||||
data: ErrorResponse
|
||||
status: 500
|
||||
}
|
||||
|
||||
export type createActivityResponseSuccess = (createActivityResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type createActivityResponseError = (createActivityResponse400 | createActivityResponse401 | createActivityResponse500) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type createActivityResponse = (createActivityResponseSuccess | createActivityResponseError)
|
||||
|
||||
export const getCreateActivityUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/v1/activity`
|
||||
}
|
||||
|
||||
export const createActivity = async (createActivityRequest: CreateActivityRequest, options?: RequestInit): Promise<createActivityResponse> => {
|
||||
|
||||
const res = await fetch(getCreateActivityUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
createActivityRequest,)
|
||||
}
|
||||
)
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: createActivityResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as createActivityResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getCreateActivityMutationOptions = <TError = ErrorResponse,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createActivity>>, TError,{data: CreateActivityRequest}, TContext>, fetch?: RequestInit}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof createActivity>>, TError,{data: CreateActivityRequest}, TContext> => {
|
||||
|
||||
const mutationKey = ['createActivity'];
|
||||
const {mutation: mutationOptions, fetch: fetchOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, fetch: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createActivity>>, {data: CreateActivityRequest}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return createActivity(data,fetchOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type CreateActivityMutationResult = NonNullable<Awaited<ReturnType<typeof createActivity>>>
|
||||
export type CreateActivityMutationBody = CreateActivityRequest
|
||||
export type CreateActivityMutationError = ErrorResponse
|
||||
|
||||
/**
|
||||
* @summary Create activity records
|
||||
*/
|
||||
export const useCreateActivity = <TError = ErrorResponse,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createActivity>>, TError,{data: CreateActivityRequest}, TContext>, fetch?: RequestInit}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createActivity>>,
|
||||
TError,
|
||||
{data: CreateActivityRequest},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateActivityMutationOptions(options), queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get user settings
|
||||
*/
|
||||
|
||||
15
frontend/src/generated/model/createActivityItem.ts
Normal file
15
frontend/src/generated/model/createActivityItem.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface CreateActivityItem {
|
||||
document_id: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
}
|
||||
14
frontend/src/generated/model/createActivityRequest.ts
Normal file
14
frontend/src/generated/model/createActivityRequest.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { CreateActivityItem } from './createActivityItem';
|
||||
|
||||
export interface CreateActivityRequest {
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
activity: CreateActivityItem[];
|
||||
}
|
||||
11
frontend/src/generated/model/createActivityResponse.ts
Normal file
11
frontend/src/generated/model/createActivityResponse.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface CreateActivityResponse {
|
||||
added: number;
|
||||
}
|
||||
@@ -6,9 +6,7 @@
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Document } from './document';
|
||||
import type { Progress } from './progress';
|
||||
|
||||
export interface DocumentResponse {
|
||||
document: Document;
|
||||
progress?: Progress;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Document } from './document';
|
||||
import type { UserData } from './userData';
|
||||
import type { WordCount } from './wordCount';
|
||||
|
||||
export interface DocumentsResponse {
|
||||
documents: Document[];
|
||||
@@ -17,6 +15,4 @@ export interface DocumentsResponse {
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
search?: string;
|
||||
user: UserData;
|
||||
word_counts: WordCount[];
|
||||
}
|
||||
|
||||
@@ -8,4 +8,12 @@
|
||||
|
||||
export type GetLogsParams = {
|
||||
filter?: string;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,9 @@ export * from './activity';
|
||||
export * from './activityResponse';
|
||||
export * from './backupType';
|
||||
export * from './configResponse';
|
||||
export * from './createActivityItem';
|
||||
export * from './createActivityRequest';
|
||||
export * from './createActivityResponse';
|
||||
export * from './createDocumentBody';
|
||||
export * from './databaseInfo';
|
||||
export * from './device';
|
||||
@@ -57,6 +60,8 @@ export * from './setting';
|
||||
export * from './settingsResponse';
|
||||
export * from './streaksResponse';
|
||||
export * from './updateDocumentBody';
|
||||
export * from './updateProgressRequest';
|
||||
export * from './updateProgressResponse';
|
||||
export * from './updateSettingsRequest';
|
||||
export * from './updateUserBody';
|
||||
export * from './uploadDocumentCoverBody';
|
||||
|
||||
@@ -10,4 +10,9 @@ import type { LogEntry } from './logEntry';
|
||||
export interface LogsResponse {
|
||||
logs?: LogEntry[];
|
||||
filter?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ export interface Progress {
|
||||
title?: string;
|
||||
author?: string;
|
||||
device_name?: string;
|
||||
device_id?: string;
|
||||
percentage?: number;
|
||||
progress?: string;
|
||||
document_id?: string;
|
||||
user_id?: string;
|
||||
created_at?: string;
|
||||
|
||||
15
frontend/src/generated/model/updateProgressRequest.ts
Normal file
15
frontend/src/generated/model/updateProgressRequest.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UpdateProgressRequest {
|
||||
document_id: string;
|
||||
percentage: number;
|
||||
progress: string;
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
}
|
||||
12
frontend/src/generated/model/updateProgressResponse.ts
Normal file
12
frontend/src/generated/model/updateProgressResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UpdateProgressResponse {
|
||||
document_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
69
frontend/src/hooks/useDebounce.test.tsx
Normal file
69
frontend/src/hooks/useDebounce.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
describe('useDebounce', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns the initial value immediately', () => {
|
||||
const { result } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'initial', delay: 300 },
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
});
|
||||
|
||||
it('delays updates until the debounce interval has passed', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'initial', delay: 300 },
|
||||
});
|
||||
|
||||
rerender({ value: 'updated', delay: 300 });
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(299);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('updated');
|
||||
});
|
||||
|
||||
it('cancels the previous timer when the value changes again', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'first', delay: 300 },
|
||||
});
|
||||
|
||||
rerender({ value: 'second', delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
rerender({ value: 'third', delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('first');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('third');
|
||||
});
|
||||
});
|
||||
237
frontend/src/hooks/useEpubReader.ts
Normal file
237
frontend/src/hooks/useEpubReader.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createActivity, getGetDocumentFileUrl, updateProgress } from '../generated/anthoLumeAPIV1';
|
||||
import type { CreateActivityRequest } from '../generated/model/createActivityRequest';
|
||||
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
|
||||
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
|
||||
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
|
||||
|
||||
interface UseEpubReaderOptions {
|
||||
documentId: string;
|
||||
initialProgress?: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
colorScheme: ReaderColorScheme;
|
||||
fontFamily: ReaderFontFamily;
|
||||
fontSize: number;
|
||||
isPaginationDisabled: () => boolean;
|
||||
onSwipeDown: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onCenterTap: () => void;
|
||||
}
|
||||
|
||||
interface UseEpubReaderResult {
|
||||
viewerRef: (_node: HTMLDivElement | null) => void;
|
||||
isReady: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
toc: ReaderTocItem[];
|
||||
stats: ReaderStats;
|
||||
nextPage: () => Promise<void>;
|
||||
prevPage: () => Promise<void>;
|
||||
goToHref: (href: string) => Promise<void>;
|
||||
setTheme: (theme: {
|
||||
colorScheme?: ReaderColorScheme;
|
||||
fontFamily?: ReaderFontFamily;
|
||||
fontSize?: number;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useEpubReader({
|
||||
documentId,
|
||||
initialProgress,
|
||||
deviceId,
|
||||
deviceName,
|
||||
colorScheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
isPaginationDisabled,
|
||||
onSwipeDown,
|
||||
onSwipeUp,
|
||||
onCenterTap,
|
||||
}: UseEpubReaderOptions): UseEpubReaderResult {
|
||||
const [viewerNode, setViewerNode] = useState<HTMLDivElement | null>(null);
|
||||
const readerRef = useRef<EBookReader | null>(null);
|
||||
const isPaginationDisabledRef = useRef(isPaginationDisabled);
|
||||
const onSwipeDownRef = useRef(onSwipeDown);
|
||||
const onSwipeUpRef = useRef(onSwipeUp);
|
||||
const onCenterTapRef = useRef(onCenterTap);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toc, setToc] = useState<ReaderTocItem[]>([]);
|
||||
const [stats, setStats] = useState<ReaderStats>({
|
||||
chapterName: 'N/A',
|
||||
sectionPage: 0,
|
||||
sectionTotalPages: 0,
|
||||
percentage: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
isPaginationDisabledRef.current = isPaginationDisabled;
|
||||
onSwipeDownRef.current = onSwipeDown;
|
||||
onSwipeUpRef.current = onSwipeUp;
|
||||
onCenterTapRef.current = onCenterTap;
|
||||
}, [isPaginationDisabled, onCenterTap, onSwipeDown, onSwipeUp]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = viewerNode;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
let reader: EBookReader | null = null;
|
||||
|
||||
setIsReady(false);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setToc([]);
|
||||
setStats({
|
||||
chapterName: 'N/A',
|
||||
sectionPage: 0,
|
||||
sectionTotalPages: 0,
|
||||
percentage: 0,
|
||||
});
|
||||
|
||||
const saveProgress = async (payload: UpdateProgressRequest) => {
|
||||
const response = await updateProgress(payload);
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
'message' in response.data ? response.data.message : 'Unable to save reader progress'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const saveActivity = async (payload: CreateActivityRequest) => {
|
||||
const response = await createActivity(payload);
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
'message' in response.data ? response.data.message : 'Unable to save reader activity'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeReader = async () => {
|
||||
try {
|
||||
const response = await fetch(getGetDocumentFileUrl(documentId));
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (!response.ok || contentType.includes('application/json')) {
|
||||
let message = 'Unable to load document file';
|
||||
try {
|
||||
const errorData = (await response.json()) as { message?: string };
|
||||
if (errorData.message) {
|
||||
message = errorData.message;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse failure and use fallback message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
reader = new EBookReader({
|
||||
container,
|
||||
bookUrl: objectUrl,
|
||||
documentId,
|
||||
initialProgress,
|
||||
deviceId,
|
||||
deviceName,
|
||||
colorScheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
onReady: () => setIsReady(true),
|
||||
onLoading: loading => setIsLoading(loading),
|
||||
onError: message => setError(message),
|
||||
onStats: nextStats => setStats(nextStats),
|
||||
onToc: nextToc => setToc(nextToc),
|
||||
onSaveProgress: saveProgress,
|
||||
onCreateActivity: saveActivity,
|
||||
isPaginationDisabled: () => isPaginationDisabledRef.current(),
|
||||
onSwipeDown: () => onSwipeDownRef.current(),
|
||||
onSwipeUp: () => onSwipeUpRef.current(),
|
||||
onCenterTap: () => onCenterTapRef.current(),
|
||||
});
|
||||
|
||||
readerRef.current = reader;
|
||||
} catch (err) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Unable to load document file');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void initializeReader();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
reader?.destroy();
|
||||
if (readerRef.current === reader) {
|
||||
readerRef.current = null;
|
||||
}
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [deviceId, deviceName, documentId, initialProgress, viewerNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const reader = readerRef.current;
|
||||
if (!reader || !isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
void reader.applyThemeChange({
|
||||
colorScheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
});
|
||||
}, [colorScheme, fontFamily, fontSize, isReady]);
|
||||
|
||||
const nextPage = useCallback(async () => {
|
||||
await readerRef.current?.nextPage();
|
||||
}, []);
|
||||
|
||||
const prevPage = useCallback(async () => {
|
||||
await readerRef.current?.prevPage();
|
||||
}, []);
|
||||
|
||||
const goToHref = useCallback(async (href: string) => {
|
||||
await readerRef.current?.displayHref(href);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
async (theme: {
|
||||
colorScheme?: ReaderColorScheme;
|
||||
fontFamily?: ReaderFontFamily;
|
||||
fontSize?: number;
|
||||
}) => {
|
||||
await readerRef.current?.applyThemeChange(theme);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
viewerRef: setViewerNode,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
toc,
|
||||
stats,
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToHref,
|
||||
setTheme,
|
||||
}),
|
||||
[error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc]
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ interface BaseIconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
hoverable?: boolean;
|
||||
viewBox?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -12,12 +13,15 @@ export function BaseIcon({
|
||||
size = 24,
|
||||
className = '',
|
||||
disabled = false,
|
||||
hoverable = true,
|
||||
viewBox = '0 0 24 24',
|
||||
children,
|
||||
}: BaseIconProps) {
|
||||
const disabledClasses = disabled
|
||||
? 'text-gray-200 dark:text-gray-600'
|
||||
: 'hover:text-gray-800 dark:hover:text-gray-100';
|
||||
? 'text-content-subtle'
|
||||
: hoverable
|
||||
? 'hover:text-content'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
export function GitIcon() {
|
||||
interface GitIconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitIcon({ size = 20, className = '' }: GitIconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-black dark:text-white"
|
||||
height="20"
|
||||
className={`${className} text-content`.trim()}
|
||||
height={size}
|
||||
viewBox="0 0 219 92"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -22,19 +27,19 @@ export function GitIcon() {
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
|
||||
/>
|
||||
<g clip-path="url(#a)">
|
||||
<g clipPath="url(#a)">
|
||||
<path
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#b)">
|
||||
<g clipPath="url(#b)">
|
||||
<path
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#c)">
|
||||
<g clipPath="url(#c)">
|
||||
<path
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
|
||||
|
||||
@@ -4,11 +4,12 @@ interface Search2IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
hoverable?: boolean;
|
||||
}
|
||||
|
||||
export function Search2Icon({ size = 24, className = '', disabled = false }: Search2IconProps) {
|
||||
export function Search2Icon({ size = 24, className = '', disabled = false, hoverable = true }: Search2IconProps) {
|
||||
return (
|
||||
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||
<BaseIcon size={size} className={className} disabled={disabled} hoverable={hoverable}>
|
||||
<rect width="24" height="24" fill="none" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -2,16 +2,208 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* PWA Styling */
|
||||
:root {
|
||||
--white: 255 255 255;
|
||||
--black: 0 0 0;
|
||||
|
||||
--canvas: 243 244 246;
|
||||
--surface: 255 255 255;
|
||||
--surface-muted: 249 250 251;
|
||||
--surface-strong: 209 213 219;
|
||||
--overlay: 31 41 55;
|
||||
|
||||
--content: 0 0 0;
|
||||
--content-muted: 107 114 128;
|
||||
--content-subtle: 156 163 175;
|
||||
--content-inverse: 255 255 255;
|
||||
|
||||
--border: 209 213 219;
|
||||
--border-muted: 229 231 235;
|
||||
--border-strong: 156 163 175;
|
||||
|
||||
--neutral-50: 249 250 251;
|
||||
--neutral-100: 243 244 246;
|
||||
--neutral-200: 229 231 235;
|
||||
--neutral-300: 209 213 219;
|
||||
--neutral-400: 156 163 175;
|
||||
--neutral-500: 107 114 128;
|
||||
--neutral-600: 75 85 99;
|
||||
--neutral-700: 55 65 81;
|
||||
--neutral-800: 31 41 55;
|
||||
--neutral-900: 17 24 39;
|
||||
|
||||
--primary-50: 250 245 255;
|
||||
--primary-100: 243 232 255;
|
||||
--primary-200: 233 213 255;
|
||||
--primary-300: 216 180 254;
|
||||
--primary-400: 192 132 252;
|
||||
--primary-500: 168 85 247;
|
||||
--primary-600: 147 51 234;
|
||||
--primary-700: 126 34 206;
|
||||
--primary-800: 107 33 168;
|
||||
--primary-900: 88 28 135;
|
||||
--primary-foreground: 255 255 255;
|
||||
|
||||
--secondary-50: 239 246 255;
|
||||
--secondary-100: 219 234 254;
|
||||
--secondary-200: 191 219 254;
|
||||
--secondary-300: 147 197 253;
|
||||
--secondary-400: 96 165 250;
|
||||
--secondary-500: 59 130 246;
|
||||
--secondary-600: 37 99 235;
|
||||
--secondary-700: 29 78 216;
|
||||
--secondary-800: 30 64 175;
|
||||
--secondary-900: 30 58 138;
|
||||
--secondary-foreground: 255 255 255;
|
||||
|
||||
--tertiary-50: 236 253 245;
|
||||
--tertiary-100: 209 250 229;
|
||||
--tertiary-200: 167 243 208;
|
||||
--tertiary-300: 110 231 183;
|
||||
--tertiary-400: 52 211 153;
|
||||
--tertiary-500: 16 185 129;
|
||||
--tertiary-600: 5 150 105;
|
||||
--tertiary-700: 4 120 87;
|
||||
--tertiary-800: 6 95 70;
|
||||
--tertiary-900: 6 78 59;
|
||||
--tertiary-foreground: 255 255 255;
|
||||
|
||||
--warning-50: 254 252 232;
|
||||
--warning-100: 254 249 195;
|
||||
--warning-200: 254 240 138;
|
||||
--warning-300: 253 224 71;
|
||||
--warning-400: 250 204 21;
|
||||
--warning-500: 234 179 8;
|
||||
--warning-600: 202 138 4;
|
||||
--warning-700: 161 98 7;
|
||||
--warning-800: 133 77 14;
|
||||
--warning-900: 113 63 18;
|
||||
--warning-foreground: 17 24 39;
|
||||
|
||||
--error-50: 254 242 242;
|
||||
--error-100: 254 226 226;
|
||||
--error-200: 254 202 202;
|
||||
--error-300: 252 165 165;
|
||||
--error-400: 248 113 113;
|
||||
--error-500: 239 68 68;
|
||||
--error-600: 220 38 38;
|
||||
--error-700: 185 28 28;
|
||||
--error-800: 153 27 27;
|
||||
--error-900: 127 29 29;
|
||||
--error-foreground: 255 255 255;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--white: 255 255 255;
|
||||
--black: 0 0 0;
|
||||
|
||||
--canvas: 31 41 55;
|
||||
--surface: 55 65 81;
|
||||
--surface-muted: 75 85 99;
|
||||
--surface-strong: 107 114 128;
|
||||
--overlay: 229 231 235;
|
||||
|
||||
--content: 255 255 255;
|
||||
--content-muted: 209 213 219;
|
||||
--content-subtle: 156 163 175;
|
||||
--content-inverse: 17 24 39;
|
||||
|
||||
--border: 75 85 99;
|
||||
--border-muted: 55 65 81;
|
||||
--border-strong: 107 114 128;
|
||||
|
||||
--neutral-50: 249 250 251;
|
||||
--neutral-100: 243 244 246;
|
||||
--neutral-200: 229 231 235;
|
||||
--neutral-300: 209 213 219;
|
||||
--neutral-400: 156 163 175;
|
||||
--neutral-500: 107 114 128;
|
||||
--neutral-600: 75 85 99;
|
||||
--neutral-700: 55 65 81;
|
||||
--neutral-800: 31 41 55;
|
||||
--neutral-900: 17 24 39;
|
||||
|
||||
--primary-50: 250 245 255;
|
||||
--primary-100: 243 232 255;
|
||||
--primary-200: 233 213 255;
|
||||
--primary-300: 216 180 254;
|
||||
--primary-400: 192 132 252;
|
||||
--primary-500: 168 85 247;
|
||||
--primary-600: 147 51 234;
|
||||
--primary-700: 126 34 206;
|
||||
--primary-800: 107 33 168;
|
||||
--primary-900: 88 28 135;
|
||||
--primary-foreground: 255 255 255;
|
||||
|
||||
--secondary-50: 239 246 255;
|
||||
--secondary-100: 219 234 254;
|
||||
--secondary-200: 191 219 254;
|
||||
--secondary-300: 147 197 253;
|
||||
--secondary-400: 96 165 250;
|
||||
--secondary-500: 59 130 246;
|
||||
--secondary-600: 37 99 235;
|
||||
--secondary-700: 29 78 216;
|
||||
--secondary-800: 30 64 175;
|
||||
--secondary-900: 30 58 138;
|
||||
--secondary-foreground: 255 255 255;
|
||||
|
||||
--tertiary-50: 236 253 245;
|
||||
--tertiary-100: 209 250 229;
|
||||
--tertiary-200: 167 243 208;
|
||||
--tertiary-300: 110 231 183;
|
||||
--tertiary-400: 52 211 153;
|
||||
--tertiary-500: 16 185 129;
|
||||
--tertiary-600: 5 150 105;
|
||||
--tertiary-700: 4 120 87;
|
||||
--tertiary-800: 6 95 70;
|
||||
--tertiary-900: 6 78 59;
|
||||
--tertiary-foreground: 255 255 255;
|
||||
|
||||
--warning-50: 254 252 232;
|
||||
--warning-100: 254 249 195;
|
||||
--warning-200: 254 240 138;
|
||||
--warning-300: 253 224 71;
|
||||
--warning-400: 250 204 21;
|
||||
--warning-500: 234 179 8;
|
||||
--warning-600: 202 138 4;
|
||||
--warning-700: 161 98 7;
|
||||
--warning-800: 133 77 14;
|
||||
--warning-900: 113 63 18;
|
||||
--warning-foreground: 17 24 39;
|
||||
|
||||
--error-50: 254 242 242;
|
||||
--error-100: 254 226 226;
|
||||
--error-200: 254 202 202;
|
||||
--error-300: 252 165 165;
|
||||
--error-400: 248 113 113;
|
||||
--error-500: 239 68 68;
|
||||
--error-600: 220 38 38;
|
||||
--error-700: 185 28 28;
|
||||
--error-800: 153 27 27;
|
||||
--error-900: 127 29 29;
|
||||
--error-foreground: 255 255 255;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
margin: 0px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
height: calc(100% + env(safe-area-inset-bottom));
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
|
||||
background-color: rgb(var(--canvas));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: rgb(var(--canvas));
|
||||
color: rgb(var(--content));
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -46,7 +238,7 @@ main {
|
||||
|
||||
/* Mobile Navigation */
|
||||
#mobile-nav-button span {
|
||||
transform-origin: 5px 0px;
|
||||
transform-origin: 5px 0;
|
||||
transition:
|
||||
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
@@ -54,11 +246,11 @@ main {
|
||||
}
|
||||
|
||||
#mobile-nav-button span:first-child {
|
||||
transform-origin: 0% 0%;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:nth-last-child(2) {
|
||||
transform-origin: 0% 100%;
|
||||
transform-origin: 0 100%;
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span {
|
||||
@@ -88,7 +280,7 @@ main {
|
||||
#menu {
|
||||
top: 0;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
transform-origin: 0% 0%;
|
||||
transform-origin: 0 0;
|
||||
transform: translate(-100%, 0);
|
||||
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
|
||||
}
|
||||
@@ -112,9 +304,9 @@ main {
|
||||
.animate-wave {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(229, 231, 235) 0%,
|
||||
rgb(243, 244, 246) 50%,
|
||||
rgb(229, 231, 235) 100%
|
||||
rgb(var(--neutral-200)) 0%,
|
||||
rgb(var(--neutral-100)) 50%,
|
||||
rgb(var(--neutral-200)) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: wave 1.5s ease-in-out infinite;
|
||||
@@ -123,9 +315,9 @@ main {
|
||||
.dark .animate-wave {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(75, 85, 99) 0%,
|
||||
rgb(107, 114, 128) 50%,
|
||||
rgb(75, 85, 99) 100%
|
||||
rgb(var(--neutral-600)) 0%,
|
||||
rgb(var(--neutral-500)) 50%,
|
||||
rgb(var(--neutral-600)) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
1001
frontend/src/lib/reader/EBookReader.ts
Normal file
1001
frontend/src/lib/reader/EBookReader.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,16 @@ import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from './components/ToastContext';
|
||||
import './auth/authInterceptor';
|
||||
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
initializeThemeMode();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
@@ -23,9 +25,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||
const activities = data?.status === 200 ? data.data.activities : [];
|
||||
|
||||
const columns = [
|
||||
const columns: Column<Activity>[] = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_value: Activity['document_id'], row: Activity) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
render: (_value, row) => (
|
||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||
</Link>
|
||||
),
|
||||
@@ -24,19 +21,17 @@ export default function ActivityPage() {
|
||||
{
|
||||
key: 'start_time' as const,
|
||||
header: 'Time',
|
||||
render: (value: Activity['start_time']) => value || 'N/A',
|
||||
render: value => String(value || 'N/A'),
|
||||
},
|
||||
{
|
||||
key: 'duration' as const,
|
||||
header: 'Duration',
|
||||
render: (value: Activity['duration']) => {
|
||||
return formatDuration(value || 0);
|
||||
},
|
||||
render: value => formatDuration(typeof value === 'number' ? value : 0),
|
||||
},
|
||||
{
|
||||
key: 'end_percentage' as const,
|
||||
header: 'Percent',
|
||||
render: (value: Activity['end_percentage']) => (value != null ? `${value}%` : '0%'),
|
||||
render: value => (typeof value === 'number' ? `${value}%` : '0%'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import type { DirectoryItem, DirectoryListResponse } from '../generated/model';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
@@ -17,8 +18,10 @@ export default function AdminImportPage() {
|
||||
|
||||
const postImport = usePostImport();
|
||||
|
||||
const directories = directoryData?.data?.items || [];
|
||||
const currentPathDisplay = directoryData?.data?.current_path ?? currentPath ?? '/data';
|
||||
const directoryResponse =
|
||||
directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null;
|
||||
const directories = directoryResponse?.items ?? [];
|
||||
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
||||
|
||||
const handleSelectDirectory = (directory: string) => {
|
||||
setSelectedDirectory(`${currentPath}/${directory}`);
|
||||
@@ -45,7 +48,6 @@ export default function AdminImportPage() {
|
||||
{
|
||||
onSuccess: _response => {
|
||||
showInfo('Import completed successfully');
|
||||
// Redirect to import results page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/import-results';
|
||||
}, 1500);
|
||||
@@ -62,22 +64,22 @@ export default function AdminImportPage() {
|
||||
};
|
||||
|
||||
if (isLoading && !currentPath) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
if (selectedDirectory) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<p className="text-lg font-semibold text-gray-500">Selected Import Directory</p>
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||
<div className="flex w-full justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 text-content">
|
||||
<FolderOpenIcon size={20} />
|
||||
<p className="break-all text-lg font-medium">{selectedDirectory}</p>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col justify-around gap-2">
|
||||
<div className="mr-4 flex flex-col justify-around gap-2 text-content">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -121,20 +123,20 @@ export default function AdminImportPage() {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800"></th>
|
||||
<th className="break-all border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800">
|
||||
<th className="w-12 border-b border-border p-3 text-left font-normal"></th>
|
||||
<th className="break-all border-b border-border p-3 text-left font-normal">
|
||||
{currentPath}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
<tbody>
|
||||
{currentPath !== '/' && (
|
||||
<tr>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"></td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3 text-content-muted"></td>
|
||||
<td className="border-b border-border p-3">
|
||||
<button onClick={handleNavigateUp}>
|
||||
<p>../</p>
|
||||
</button>
|
||||
@@ -148,14 +150,14 @@ export default function AdminImportPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
directories.map(item => (
|
||||
directories.map((item: DirectoryItem) => (
|
||||
<tr key={item.name}>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
||||
<td className="border-b border-border p-3 text-content-muted">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<FolderOpenIcon size={20} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<p>{item.name ?? ''}</p>
|
||||
</button>
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
||||
import type { ImportResult } from '../generated/model/importResult';
|
||||
import type { ImportResult, ImportResultsResponse } from '../generated/model';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function AdminImportResultsPage() {
|
||||
const { data: resultsData, isLoading } = useGetImportResults();
|
||||
const results = resultsData?.data?.results || [];
|
||||
const results =
|
||||
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Document
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Error
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
<tbody>
|
||||
{results.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={3}>
|
||||
@@ -38,22 +39,24 @@ export default function AdminImportResultsPage() {
|
||||
results.map((result: ImportResult, index: number) => (
|
||||
<tr key={index}>
|
||||
<td
|
||||
className="grid border-b border-gray-200 p-3"
|
||||
className="grid border-b border-border p-3"
|
||||
style={{ gridTemplateColumns: '4rem auto' }}
|
||||
>
|
||||
<span className="text-gray-800 dark:text-gray-400">Name:</span>
|
||||
<span className="text-content-muted">Name:</span>
|
||||
{result.id ? (
|
||||
<Link to={`/documents/${result.id}`}>{result.name}</Link>
|
||||
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
|
||||
{result.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>N/A</span>
|
||||
)}
|
||||
<span className="text-gray-800 dark:text-gray-400">File:</span>
|
||||
<span className="text-content-muted">File:</span>
|
||||
<span>{result.path}</span>
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{result.status}</p>
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{result.error || ''}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||
import type { LogsResponse } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { SearchIcon } from '../icons';
|
||||
import { LoadingState } from '../components';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { Search2Icon } from '../icons';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState('');
|
||||
const debouncedFilter = useDebounce(filter, 300);
|
||||
|
||||
const { data: logsData, isLoading, refetch } = useGetLogs(filter ? { filter } : {});
|
||||
useEffect(() => {
|
||||
setActiveFilter(debouncedFilter);
|
||||
}, [debouncedFilter]);
|
||||
|
||||
const logs = logsData?.data?.logs || [];
|
||||
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
||||
|
||||
const logs = logsData?.status === 200 ? ((logsData.data as LogsResponse).logs ?? []) : [];
|
||||
|
||||
const handleFilterSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
setActiveFilter(filter);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter Form */}
|
||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<SearchIcon size={15} />
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white p-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface p-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="JQ Filter"
|
||||
/>
|
||||
</div>
|
||||
@@ -46,16 +50,19 @@ export default function AdminLogsPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Log Display */}
|
||||
<div
|
||||
className="flex w-full flex-col-reverse overflow-scroll text-black dark:text-white"
|
||||
className="flex w-full flex-col-reverse overflow-scroll text-content"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{logs.map((log: string, index: number) => (
|
||||
{isLoading ? (
|
||||
<LoadingState className="min-h-40 w-full" />
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
|
||||
{log}
|
||||
{typeof log === 'string' ? log : JSON.stringify(log)}
|
||||
</span>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ interface BackupTypes {
|
||||
export default function AdminPage() {
|
||||
const { isLoading } = useGetAdmin();
|
||||
const postAdminAction = usePostAdminAction();
|
||||
const { showInfo, showError } = useToasts();
|
||||
const { showInfo, showError, removeToast } = useToasts();
|
||||
|
||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||
covers: false,
|
||||
@@ -42,19 +42,14 @@ export default function AdminPage() {
|
||||
|
||||
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
|
||||
|
||||
// Stream the response directly to disk using File System Access API
|
||||
// This avoids loading multi-GB files into browser memory
|
||||
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
|
||||
try {
|
||||
// Modern browsers: Use File System Access API for direct disk writes
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
|
||||
const writable = await handle.createWritable();
|
||||
|
||||
// Stream response body directly to file without buffering
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Unable to read response');
|
||||
|
||||
@@ -67,13 +62,11 @@ export default function AdminPage() {
|
||||
await writable.close();
|
||||
showInfo('Backup completed successfully');
|
||||
} catch (err) {
|
||||
// User cancelled or error
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
showError('Backup failed: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
showError(
|
||||
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
|
||||
);
|
||||
@@ -83,76 +76,64 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreSubmit = (e: FormEvent) => {
|
||||
const handleRestoreSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!restoreFile) return;
|
||||
|
||||
postAdminAction.mutate(
|
||||
{
|
||||
const startedToastId = showInfo('Restore started', 0);
|
||||
|
||||
try {
|
||||
const response = await postAdminAction.mutateAsync({
|
||||
data: {
|
||||
action: 'RESTORE',
|
||||
restore_file: restoreFile,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
});
|
||||
|
||||
removeToast(startedToastId);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Restore completed successfully');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
},
|
||||
return;
|
||||
}
|
||||
|
||||
showError('Restore failed: ' + getErrorMessage(response.data));
|
||||
} catch (error) {
|
||||
removeToast(startedToastId);
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleMetadataMatch = () => {
|
||||
postAdminAction.mutate(
|
||||
{ data: { action: 'METADATA_MATCH' } },
|
||||
{
|
||||
data: {
|
||||
action: 'METADATA_MATCH',
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showInfo('Metadata matching started');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Metadata matching failed: ' + getErrorMessage(error));
|
||||
},
|
||||
onSuccess: () => showInfo('Metadata matching started'),
|
||||
onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCacheTables = () => {
|
||||
postAdminAction.mutate(
|
||||
{ data: { action: 'CACHE_TABLES' } },
|
||||
{
|
||||
data: {
|
||||
action: 'CACHE_TABLES',
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showInfo('Cache tables started');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Cache tables failed: ' + getErrorMessage(error));
|
||||
},
|
||||
onSuccess: () => showInfo('Cache tables started'),
|
||||
onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full grow flex-col gap-4">
|
||||
{/* Backup & Restore Card */}
|
||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<p className="mb-2 text-lg font-semibold">Backup & Restore</p>
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Backup & Restore</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Backup Form */}
|
||||
<form className="flex justify-between" onSubmit={handleBackupSubmit}>
|
||||
<form className="flex justify-between text-content" onSubmit={handleBackupSubmit}>
|
||||
<div className="flex items-center gap-8">
|
||||
<div>
|
||||
<input
|
||||
@@ -180,8 +161,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Restore Form */}
|
||||
<form onSubmit={handleRestoreSubmit} className="flex grow justify-between">
|
||||
<form onSubmit={handleRestoreSubmit} className="flex grow justify-between text-content">
|
||||
<div className="flex w-1/2 items-center">
|
||||
<input
|
||||
type="file"
|
||||
@@ -191,7 +171,7 @@ export default function AdminPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="h-10 w-40">
|
||||
<Button variant="secondary" type="submit">
|
||||
<Button variant="secondary" type="submit" disabled={!restoreFile}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
@@ -199,11 +179,10 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Card */}
|
||||
<div className="flex grow flex-col rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<p className="text-lg font-semibold">Tasks</p>
|
||||
<table className="min-w-full bg-white text-sm dark:bg-gray-700">
|
||||
<tbody className="text-black dark:text-white">
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Tasks</p>
|
||||
<table className="min-w-full bg-surface text-sm text-content">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="pl-0">
|
||||
<p>Metadata Matching</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||
import type { User, UsersResponse } from '../generated/model';
|
||||
import { AddIcon, DeleteIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -14,7 +15,7 @@ export default function AdminUsersPage() {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
||||
|
||||
const users = usersData?.data?.users || [];
|
||||
const users = usersData?.status === 200 ? ((usersData.data as UsersResponse).users ?? []) : [];
|
||||
|
||||
const handleCreateUser = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -30,7 +31,12 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to create user: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo('User created successfully');
|
||||
setShowAddForm(false);
|
||||
setNewUsername('');
|
||||
@@ -38,9 +44,7 @@ export default function AdminUsersPage() {
|
||||
setNewIsAdmin(false);
|
||||
refetch();
|
||||
},
|
||||
onError: error => {
|
||||
showError('Failed to create user: ' + getErrorMessage(error));
|
||||
},
|
||||
onError: error => showError('Failed to create user: ' + getErrorMessage(error)),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -48,19 +52,19 @@ export default function AdminUsersPage() {
|
||||
const handleDeleteUser = (userId: string) => {
|
||||
updateUser.mutate(
|
||||
{
|
||||
data: {
|
||||
operation: 'DELETE',
|
||||
user: userId,
|
||||
},
|
||||
data: { operation: 'DELETE', user: userId },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to delete user: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo('User deleted successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: error => {
|
||||
showError('Failed to delete user: ' + getErrorMessage(error));
|
||||
},
|
||||
onError: error => showError('Failed to delete user: ' + getErrorMessage(error)),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -70,20 +74,19 @@ export default function AdminUsersPage() {
|
||||
|
||||
updateUser.mutate(
|
||||
{
|
||||
data: {
|
||||
operation: 'UPDATE',
|
||||
user: userId,
|
||||
password: password,
|
||||
},
|
||||
data: { operation: 'UPDATE', user: userId, password },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to update password: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo('Password updated successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: error => {
|
||||
showError('Failed to update password: ' + getErrorMessage(error));
|
||||
},
|
||||
onError: error => showError('Failed to update password: ' + getErrorMessage(error)),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -91,51 +94,45 @@ export default function AdminUsersPage() {
|
||||
const handleToggleAdmin = (userId: string, isAdmin: boolean) => {
|
||||
updateUser.mutate(
|
||||
{
|
||||
data: {
|
||||
operation: 'UPDATE',
|
||||
user: userId,
|
||||
is_admin: isAdmin,
|
||||
},
|
||||
data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
const role = isAdmin ? 'admin' : 'user';
|
||||
showInfo(`User permissions updated to ${role}`);
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to update admin status: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`);
|
||||
refetch();
|
||||
},
|
||||
onError: error => {
|
||||
showError('Failed to update admin status: ' + getErrorMessage(error));
|
||||
},
|
||||
onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-x-auto">
|
||||
{/* Add User Form */}
|
||||
{showAddForm && (
|
||||
<div className="absolute left-10 top-10 rounded bg-gray-200 p-3 shadow-lg shadow-gray-500 transition-all duration-200 dark:bg-gray-600 dark:shadow-gray-900">
|
||||
<form
|
||||
onSubmit={handleCreateUser}
|
||||
className="flex flex-col gap-2 text-sm text-black dark:text-white"
|
||||
>
|
||||
<div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
|
||||
<form onSubmit={handleCreateUser} className="flex flex-col gap-2 text-sm text-content">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
||||
className="bg-surface p-2 text-content"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
||||
className="bg-surface p-2 text-content"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -147,7 +144,7 @@ export default function AdminUsersPage() {
|
||||
<label htmlFor="new_is_admin">Admin</label>
|
||||
</div>
|
||||
<button
|
||||
className="bg-gray-500 px-2 py-1 font-medium text-white hover:bg-gray-800 dark:text-gray-800 dark:hover:bg-gray-100"
|
||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
@@ -156,31 +153,26 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="min-w-full overflow-scroll rounded shadow">
|
||||
<table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase">
|
||||
<button onClick={() => setShowAddForm(!showAddForm)}>
|
||||
<AddIcon size={20} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
User
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
Password
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-center font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">User</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">Password</th>
|
||||
<th className="border-b border-border p-3 text-center font-normal uppercase">
|
||||
Permissions
|
||||
</th>
|
||||
<th className="w-48 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="w-48 border-b border-border p-3 text-left font-normal uppercase">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={5}>
|
||||
@@ -188,39 +180,35 @@ export default function AdminUsersPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map(user => (
|
||||
users.map((user: User) => (
|
||||
<tr key={user.id}>
|
||||
{/* Delete Button */}
|
||||
<td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
||||
<td className="relative cursor-pointer border-b border-border p-3 text-content-muted">
|
||||
<button onClick={() => handleDeleteUser(user.id)}>
|
||||
<DeleteIcon size={20} />
|
||||
</button>
|
||||
</td>
|
||||
{/* User ID */}
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{user.id}</p>
|
||||
</td>
|
||||
{/* Password Reset */}
|
||||
<td className="border-b border-gray-200 px-3">
|
||||
<td className="border-b border-border px-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const password = prompt(`Enter new password for ${user.id}`);
|
||||
if (password) handleUpdatePassword(user.id, password);
|
||||
}}
|
||||
className="bg-gray-500 px-2 py-1 font-medium text-white hover:bg-gray-800 dark:text-gray-800 dark:hover:bg-gray-100"
|
||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</td>
|
||||
{/* Admin Toggle */}
|
||||
<td className="flex min-w-40 justify-center gap-2 border-b border-gray-200 p-3 text-center">
|
||||
<td className="flex min-w-40 justify-center gap-2 border-b border-border p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user.id, true)}
|
||||
disabled={user.admin}
|
||||
className={`rounded-md px-2 py-1 text-white dark:text-black ${
|
||||
className={`rounded-md px-2 py-1 ${
|
||||
user.admin
|
||||
? 'cursor-default bg-gray-800 dark:bg-gray-100'
|
||||
: 'cursor-pointer bg-gray-400 dark:bg-gray-600'
|
||||
? 'cursor-default bg-content text-content-inverse'
|
||||
: 'cursor-pointer bg-surface-strong text-content'
|
||||
}`}
|
||||
>
|
||||
admin
|
||||
@@ -228,17 +216,16 @@ export default function AdminUsersPage() {
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user.id, false)}
|
||||
disabled={!user.admin}
|
||||
className={`rounded-md px-2 py-1 text-white dark:text-black ${
|
||||
className={`rounded-md px-2 py-1 ${
|
||||
!user.admin
|
||||
? 'cursor-default bg-gray-800 dark:bg-gray-100'
|
||||
: 'cursor-pointer bg-gray-400 dark:bg-gray-600'
|
||||
? 'cursor-default bg-content text-content-inverse'
|
||||
: 'cursor-pointer bg-surface-strong text-content'
|
||||
}`}
|
||||
>
|
||||
user
|
||||
</button>
|
||||
</td>
|
||||
{/* Created Date */}
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{user.created_at}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -38,17 +38,16 @@ export default function ComponentDemoPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-4">
|
||||
<h1 className="text-2xl font-bold dark:text-white">UI Components Demo</h1>
|
||||
<div className="space-y-8 p-4 text-content">
|
||||
<h1 className="text-2xl font-bold">UI Components Demo</h1>
|
||||
|
||||
{/* Toast Demos */}
|
||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Toast Notifications</h2>
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Toast Notifications</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<button
|
||||
onClick={handleDemoClick}
|
||||
disabled={isLoading}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="rounded bg-secondary-500 px-4 py-2 text-secondary-foreground hover:bg-secondary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
|
||||
</button>
|
||||
@@ -66,21 +65,19 @@ export default function ComponentDemoPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCustomToast}
|
||||
className="rounded bg-purple-500 px-4 py-2 text-white hover:bg-purple-600"
|
||||
className="rounded bg-primary-500 px-4 py-2 text-primary-foreground hover:bg-primary-600"
|
||||
>
|
||||
Show Custom Toast
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skeleton Demos */}
|
||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Loading Components</h2>
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Skeleton Loading Components</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
{/* Basic Skeletons */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Basic Skeletons</h3>
|
||||
<h3 className="text-lg font-medium text-content-muted">Basic Skeletons</h3>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton variant="text" className="w-3/4" />
|
||||
@@ -92,16 +89,14 @@ export default function ComponentDemoPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton Text */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Text</h3>
|
||||
<h3 className="text-lg font-medium text-content-muted">Skeleton Text</h3>
|
||||
<SkeletonText lines={3} />
|
||||
<SkeletonText lines={5} className="max-w-md" />
|
||||
</div>
|
||||
|
||||
{/* Skeleton Avatar */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Avatar</h3>
|
||||
<h3 className="text-lg font-medium text-content-muted">Skeleton Avatar</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<SkeletonAvatar size="sm" />
|
||||
<SkeletonAvatar size="md" />
|
||||
@@ -110,9 +105,8 @@ export default function ComponentDemoPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton Button */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Button</h3>
|
||||
<h3 className="text-lg font-medium text-content-muted">Skeleton Button</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SkeletonButton width={120} />
|
||||
<SkeletonButton className="w-full max-w-xs" />
|
||||
@@ -121,9 +115,8 @@ export default function ComponentDemoPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skeleton Card Demo */}
|
||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Cards</h2>
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Skeleton Cards</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard showAvatar />
|
||||
@@ -131,33 +124,30 @@ export default function ComponentDemoPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skeleton Table Demo */}
|
||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Table</h2>
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Skeleton Table</h2>
|
||||
<SkeletonTable rows={5} columns={4} />
|
||||
</section>
|
||||
|
||||
{/* Page Loader Demo */}
|
||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Page Loader</h2>
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Page Loader</h2>
|
||||
<PageLoader message="Loading demo content..." />
|
||||
</section>
|
||||
|
||||
{/* Inline Loader Demo */}
|
||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Inline Loader</h2>
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Inline Loader</h2>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="text-center">
|
||||
<InlineLoader size="sm" />
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Small</p>
|
||||
<p className="mt-2 text-sm text-content-muted">Small</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<InlineLoader size="md" />
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Medium</p>
|
||||
<p className="mt-2 text-sm text-content-muted">Medium</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<InlineLoader size="lg" />
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Large</p>
|
||||
<p className="mt-2 text-sm text-content-muted">Large</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useGetDocument,
|
||||
useEditDocument,
|
||||
getGetDocumentQueryKey,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import { Document } from '../generated/model/document';
|
||||
import { Progress } from '../generated/model/progress';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import {
|
||||
DeleteIcon,
|
||||
@@ -18,9 +18,14 @@ import {
|
||||
CloseIcon,
|
||||
CheckIcon,
|
||||
} from '../icons';
|
||||
import { useState } from 'react';
|
||||
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
|
||||
|
||||
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
||||
const popupClassName = 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
||||
const popupInputClassName = 'rounded bg-surface p-2 text-content';
|
||||
const editInputClassName =
|
||||
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-none focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500';
|
||||
|
||||
export default function DocumentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -35,51 +40,40 @@ export default function DocumentPage() {
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||
|
||||
// Edit values - initialized after document is loaded
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editAuthor, setEditAuthor] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
if (docLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
// Check for successful response (status 200)
|
||||
if (!docData || docData.status !== 200) {
|
||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
const document = docData.data.document as Document;
|
||||
const progress =
|
||||
docData?.status === 200 ? (docData.data.progress as Progress | undefined) : undefined;
|
||||
|
||||
if (!document) {
|
||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
const percentage =
|
||||
document.percentage ?? (progress?.percentage ? progress.percentage * 100 : 0) ?? 0;
|
||||
const percentage = document.percentage ?? 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
// Helper to start editing
|
||||
const startEditing = (field: 'title' | 'author' | 'description') => {
|
||||
if (field === 'title') setEditTitle(document.title);
|
||||
if (field === 'author') setEditAuthor(document.author);
|
||||
if (field === 'description') setEditDescription(document.description || '');
|
||||
};
|
||||
|
||||
// Save edit handlers
|
||||
const saveTitle = () => {
|
||||
editMutation.mutate(
|
||||
{
|
||||
id: document.id,
|
||||
data: { title: editTitle },
|
||||
},
|
||||
{ id: document.id, data: { title: editTitle } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingTitle(false);
|
||||
// Update cache with the response data (no refetch needed)
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingTitle(false),
|
||||
@@ -89,14 +83,10 @@ export default function DocumentPage() {
|
||||
|
||||
const saveAuthor = () => {
|
||||
editMutation.mutate(
|
||||
{
|
||||
id: document.id,
|
||||
data: { author: editAuthor },
|
||||
},
|
||||
{ id: document.id, data: { author: editAuthor } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingAuthor(false);
|
||||
// Update cache with the response data (no refetch needed)
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingAuthor(false),
|
||||
@@ -106,14 +96,10 @@ export default function DocumentPage() {
|
||||
|
||||
const saveDescription = () => {
|
||||
editMutation.mutate(
|
||||
{
|
||||
id: document.id,
|
||||
data: { description: editDescription },
|
||||
},
|
||||
{ id: document.id, data: { description: editDescription } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingDescription(false);
|
||||
// Update cache with the response data (no refetch needed)
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingDescription(false),
|
||||
@@ -123,10 +109,8 @@ export default function DocumentPage() {
|
||||
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<div className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
{/* Document Info - Left Column */}
|
||||
<div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
|
||||
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
||||
{/* Cover Image with Edit Label */}
|
||||
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
@@ -135,31 +119,27 @@ export default function DocumentPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Read Button - Only if file exists */}
|
||||
{document.filepath && (
|
||||
<a
|
||||
href={`/reader#id=${document.id}&type=REMOTE`}
|
||||
className="z-10 mt-2 w-full rounded bg-blue-700 py-1 text-center text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
href={`/reader/${document.id}`}
|
||||
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-white hover:bg-secondary-800 focus:outline-none focus:ring-4 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Read
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Action Buttons Container */}
|
||||
<div className="relative z-20 flex flex-wrap-reverse justify-between gap-2">
|
||||
{/* ISBN Info */}
|
||||
<div className="min-w-[50%] md:mr-2">
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-gray-500">ISBN-10:</p>
|
||||
<p className="text-content-muted">ISBN-10:</p>
|
||||
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-gray-500">ISBN-13:</p>
|
||||
<p className="text-content-muted">ISBN-13:</p>
|
||||
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Cover Dropdown */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -169,35 +149,24 @@ export default function DocumentPage() {
|
||||
onChange={e => setShowEditCover(e.target.checked)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
||||
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 ${popupClassName} ${
|
||||
showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white">
|
||||
<input
|
||||
type="file"
|
||||
id="cover_file"
|
||||
name="cover_file"
|
||||
className="bg-gray-300 p-2"
|
||||
/>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||
<input type="file" id="cover_file" name="cover_file" className={popupInputClassName} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Upload Cover
|
||||
</button>
|
||||
</form>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
id="remove_cover"
|
||||
name="remove_cover"
|
||||
className="hidden"
|
||||
/>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||
<input type="checkbox" checked id="remove_cover" name="remove_cover" className="hidden" />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Remove Cover
|
||||
</button>
|
||||
@@ -205,24 +174,22 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icons Container */}
|
||||
<div className="relative my-auto flex grow justify-between text-gray-500 dark:text-gray-500">
|
||||
{/* Delete Button */}
|
||||
<div className="relative my-auto flex grow justify-between text-content-muted">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDelete(!showDelete)}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={iconButtonClassName}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<DeleteIcon size={28} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute bottom-7 left-5 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
||||
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||
showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="w-24 text-sm text-black dark:text-white">
|
||||
<form className="w-24 text-sm">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
|
||||
@@ -233,38 +200,36 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Button */}
|
||||
<a
|
||||
href={`/activity?document=${document.id}`}
|
||||
aria-label="Activity"
|
||||
className="hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<ActivityIcon size={28} />
|
||||
</a>
|
||||
|
||||
{/* Identify/Search Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIdentify(!showIdentify)}
|
||||
aria-label="Identify"
|
||||
className="hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<SearchIcon size={28} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute bottom-7 left-5 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
||||
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||
showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="flex flex-col gap-2 text-sm text-black dark:text-white">
|
||||
<form className="flex flex-col gap-2 text-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="Title"
|
||||
defaultValue={document.title}
|
||||
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -272,7 +237,7 @@ export default function DocumentPage() {
|
||||
name="author"
|
||||
placeholder="Author"
|
||||
defaultValue={document.author}
|
||||
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -280,11 +245,11 @@ export default function DocumentPage() {
|
||||
name="isbn"
|
||||
placeholder="ISBN 10 / ISBN 13"
|
||||
defaultValue={document.isbn13 || document.isbn10}
|
||||
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Identify
|
||||
</button>
|
||||
@@ -292,17 +257,16 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
{document.filepath ? (
|
||||
<a
|
||||
href={`/api/v1/documents/${document.id}/file`}
|
||||
aria-label="Download"
|
||||
className="hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<DownloadIcon size={28} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-200 dark:text-gray-600">
|
||||
<span className="text-content-subtle">
|
||||
<DownloadIcon size={28} disabled />
|
||||
</span>
|
||||
)}
|
||||
@@ -310,9 +274,7 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Details Grid */}
|
||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||
{/* Title - Editable */}
|
||||
<Field
|
||||
isEditing={isEditingTitle}
|
||||
label={
|
||||
@@ -321,20 +283,10 @@ export default function DocumentPage() {
|
||||
<FieldActions>
|
||||
{isEditingTitle ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingTitle(false)}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<button type="button" onClick={() => setIsEditingTitle(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveTitle}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Confirm edit"
|
||||
>
|
||||
<button type="button" onClick={saveTitle} className={iconButtonClassName} aria-label="Confirm edit">
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -345,7 +297,7 @@ export default function DocumentPage() {
|
||||
startEditing('title');
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit title"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
@@ -357,19 +309,13 @@ export default function DocumentPage() {
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="w-full rounded border border-blue-200 bg-blue-50 p-2 text-lg font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
|
||||
/>
|
||||
<input type="text" value={editTitle} onChange={e => setEditTitle(e.target.value)} className={editInputClassName} />
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.title}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Author - Editable */}
|
||||
<Field
|
||||
isEditing={isEditingAuthor}
|
||||
label={
|
||||
@@ -378,20 +324,10 @@ export default function DocumentPage() {
|
||||
<FieldActions>
|
||||
{isEditingAuthor ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingAuthor(false)}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<button type="button" onClick={() => setIsEditingAuthor(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveAuthor}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Confirm edit"
|
||||
>
|
||||
<button type="button" onClick={saveAuthor} className={iconButtonClassName} aria-label="Confirm edit">
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</>
|
||||
@@ -402,7 +338,7 @@ export default function DocumentPage() {
|
||||
startEditing('author');
|
||||
setIsEditingAuthor(true);
|
||||
}}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit author"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
@@ -414,19 +350,13 @@ export default function DocumentPage() {
|
||||
>
|
||||
{isEditingAuthor ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editAuthor}
|
||||
onChange={e => setEditAuthor(e.target.value)}
|
||||
className="w-full rounded border border-blue-200 bg-blue-50 p-2 text-lg font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
|
||||
/>
|
||||
<input type="text" value={editAuthor} onChange={e => setEditAuthor(e.target.value)} className={editInputClassName} />
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.author}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Time Read with Info Dropdown */}
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
@@ -434,31 +364,27 @@ export default function DocumentPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
|
||||
className="my-auto cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={`${iconButtonClassName} my-auto`}
|
||||
aria-label="Show time read info"
|
||||
>
|
||||
<InfoIcon size={18} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute right-0 top-7 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
||||
className={`absolute right-0 top-7 z-30 ${popupClassName} ${
|
||||
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-gray-400">Seconds / Percent</p>
|
||||
<p className="font-medium dark:text-white">
|
||||
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
|
||||
</p>
|
||||
<p className="w-32 text-content-subtle">Seconds / Percent</p>
|
||||
<p className="font-medium">{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-gray-400">Words / Minute</p>
|
||||
<p className="font-medium dark:text-white">
|
||||
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
|
||||
</p>
|
||||
<p className="w-32 text-content-subtle">Words / Minute</p>
|
||||
<p className="font-medium">{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-gray-400">Est. Time Left</p>
|
||||
<p className="whitespace-nowrap font-medium dark:text-white">
|
||||
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
||||
<p className="whitespace-nowrap font-medium">
|
||||
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -473,13 +399,11 @@ export default function DocumentPage() {
|
||||
</FieldValue>
|
||||
</Field>
|
||||
|
||||
{/* Progress */}
|
||||
<Field label={<FieldLabel>Progress</FieldLabel>}>
|
||||
<FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Description - Editable */}
|
||||
<Field
|
||||
isEditing={isEditingDescription}
|
||||
label={
|
||||
@@ -488,20 +412,10 @@ export default function DocumentPage() {
|
||||
<FieldActions>
|
||||
{isEditingDescription ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingDescription(false)}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<button type="button" onClick={() => setIsEditingDescription(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveDescription}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="Confirm edit"
|
||||
>
|
||||
<button type="button" onClick={saveDescription} className={iconButtonClassName} aria-label="Confirm edit">
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</>
|
||||
@@ -512,7 +426,7 @@ export default function DocumentPage() {
|
||||
startEditing('description');
|
||||
setIsEditingDescription(true);
|
||||
}}
|
||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit description"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
@@ -527,14 +441,12 @@ export default function DocumentPage() {
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
className="h-32 w-full grow rounded border border-blue-200 bg-blue-50 p-2 font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
|
||||
className="h-32 w-full grow rounded border border-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-none focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue className="hyphens-auto text-justify">
|
||||
{document.description || 'N/A'}
|
||||
</FieldValue>
|
||||
<FieldValue className="hyphens-auto text-justify">{document.description || 'N/A'}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,140 @@
|
||||
import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document, DocumentsResponse } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||
import { LoadingState } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import {
|
||||
getDocumentsViewMode,
|
||||
setDocumentsViewMode,
|
||||
type DocumentsViewMode,
|
||||
} from '../utils/localSettings';
|
||||
|
||||
interface DocumentCardProps {
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
function DocumentCard({ doc }: DocumentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-none"
|
||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative my-auto h-48 min-w-fit">
|
||||
<Link to={`/documents/${doc.id}`}>
|
||||
<img
|
||||
className="h-full rounded object-cover"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={doc.title}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Title</p>
|
||||
<p className="text-content-subtle">Title</p>
|
||||
<p className="font-medium">{doc.title || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Author</p>
|
||||
<p className="text-content-subtle">Author</p>
|
||||
<p className="font-medium">{doc.author || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Progress</p>
|
||||
<p className="text-content-subtle">Progress</p>
|
||||
<p className="font-medium">{percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Time Read</p>
|
||||
<p className="text-content-subtle">Time Read</p>
|
||||
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
|
||||
<Link to={`/activity?document=${doc.id}`}>
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`}>
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<DownloadIcon size={20} disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentListItemProps {
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
function DocumentListItem({ doc }: DocumentListItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-none"
|
||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-content-subtle">Title</p>
|
||||
<p className="font-medium">{doc.title || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-content-subtle">Author</p>
|
||||
<p className="font-medium">{doc.author || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-content-subtle">Progress</p>
|
||||
<p className="font-medium">{percentage}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-content-subtle">Time Read</p>
|
||||
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
@@ -77,12 +151,16 @@ export default function DocumentsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(9);
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showInfo, showWarning, showError } = useToasts();
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
// Reset to page 1 when search changes
|
||||
useEffect(() => {
|
||||
setDocumentsViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
@@ -93,11 +171,6 @@ export default function DocumentsPage() {
|
||||
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
|
||||
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -128,49 +201,82 @@ export default function DocumentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getViewModeButtonClasses = (mode: DocumentsViewMode) =>
|
||||
`rounded px-3 py-1 text-sm font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-content text-content-inverse'
|
||||
: 'text-content-muted hover:bg-surface-muted'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search Form */}
|
||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<SearchIcon size={15} />
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white p-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Search Author / Title"
|
||||
name="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Search
|
||||
</Button>
|
||||
<div className="inline-flex rounded border border-border bg-surface p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={getViewModeButtonClasses('grid')}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={getViewModeButtonClasses('list')}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Document Grid */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full text-center text-gray-500 dark:text-white">Loading...</div>
|
||||
<LoadingState className="col-span-full min-h-48" />
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentCard key={doc.id} doc={doc} />)
|
||||
) : (
|
||||
docs?.map(doc => <DocumentCard key={doc.id} doc={doc} />)
|
||||
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||
No documents found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<LoadingState className="min-h-48" />
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentListItem key={doc.id} doc={doc} />)
|
||||
) : (
|
||||
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||
No documents found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex w-full justify-center gap-4 text-black dark:text-white">
|
||||
<div className="mt-4 flex w-full justify-center gap-4 text-content">
|
||||
{previousPage && previousPage > 0 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="w-24 rounded bg-white p-2 text-center text-sm font-medium shadow-lg hover:bg-gray-400 focus:outline-none dark:bg-gray-600 dark:hover:bg-gray-700"
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
|
||||
>
|
||||
◄
|
||||
</button>
|
||||
@@ -178,14 +284,13 @@ export default function DocumentsPage() {
|
||||
{nextPage && nextPage > 0 && (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="w-24 rounded bg-white p-2 text-center text-sm font-medium shadow-lg hover:bg-gray-400 focus:outline-none dark:bg-gray-600 dark:hover:bg-gray-700"
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
|
||||
>
|
||||
►
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -195,7 +300,7 @@ export default function DocumentsPage() {
|
||||
onChange={() => setUploadMode(!uploadMode)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-gray-800 p-4 text-sm text-white transition-opacity duration-200 dark:bg-gray-200 dark:text-black ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
|
||||
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200 ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
|
||||
>
|
||||
<form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
|
||||
<input
|
||||
@@ -207,7 +312,7 @@ export default function DocumentsPage() {
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
className="bg-gray-500 px-2 py-1 font-medium text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-800"
|
||||
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
@@ -221,7 +326,7 @@ export default function DocumentsPage() {
|
||||
</form>
|
||||
<label htmlFor="upload-file-button">
|
||||
<div
|
||||
className="mt-2 w-full cursor-pointer bg-gray-500 px-2 py-1 text-center font-medium text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-800"
|
||||
className="mt-2 w-full cursor-pointer bg-surface-strong px-2 py-1 text-center font-medium text-content hover:bg-surface"
|
||||
onClick={handleCancelUpload}
|
||||
>
|
||||
Cancel Upload
|
||||
@@ -229,10 +334,10 @@ export default function DocumentsPage() {
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-gray-800 opacity-30 transition-all duration-200 hover:opacity-100 dark:bg-gray-200"
|
||||
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100"
|
||||
htmlFor="upload-file-button"
|
||||
>
|
||||
<UploadIcon size={34} />
|
||||
<UploadIcon size={34} className="text-content-inverse" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,29 +17,24 @@ interface InfoCardProps {
|
||||
}
|
||||
|
||||
function InfoCard({ title, size, link }: InfoCardProps) {
|
||||
const content = (
|
||||
<div className="flex w-full gap-4 rounded bg-surface p-4 shadow-lg">
|
||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
||||
<p className="text-2xl font-bold">{size}</p>
|
||||
<p className="text-sm text-content-subtle">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className="w-full">
|
||||
<div className="flex w-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
||||
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
|
||||
<p className="text-sm text-gray-400">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex w-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
||||
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
|
||||
<p className="text-sm text-gray-400">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="w-full">{content}</div>;
|
||||
}
|
||||
|
||||
interface StreakCardProps {
|
||||
@@ -63,18 +58,18 @@ function StreakCard({
|
||||
}: StreakCardProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="relative w-full rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700">
|
||||
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
||||
<div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
</p>
|
||||
<div className="my-6 flex items-end space-x-2">
|
||||
<p className="text-5xl font-bold text-black dark:text-white">{currentStreak}</p>
|
||||
<p className="text-5xl font-bold">{currentStreak}</p>
|
||||
</div>
|
||||
<div className="dark:text-white">
|
||||
<div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2 text-sm">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-gray-400">
|
||||
<div className="flex items-end text-sm text-content-subtle">
|
||||
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +78,7 @@ function StreakCard({
|
||||
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-gray-400">
|
||||
<div className="flex items-end text-sm text-content-subtle">
|
||||
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,67 +115,47 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
|
||||
const currentData = data[selectedPeriod];
|
||||
|
||||
const handlePeriodChange = (period: TimePeriod) => {
|
||||
setSelectedPeriod(period);
|
||||
};
|
||||
const getPeriodClassName = (period: TimePeriod) =>
|
||||
`cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex size-full flex-col justify-between rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700">
|
||||
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
||||
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||
{name} Leaderboard
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodChange('all')}
|
||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'all' ? '!text-black dark:!text-white' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
|
||||
all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodChange('year')}
|
||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'year' ? '!text-black dark:!text-white' : ''}`}
|
||||
>
|
||||
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
|
||||
year
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodChange('month')}
|
||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'month' ? '!text-black dark:!text-white' : ''}`}
|
||||
>
|
||||
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
|
||||
month
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodChange('week')}
|
||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'week' ? '!text-black dark:!text-white' : ''}`}
|
||||
>
|
||||
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
|
||||
week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current period data */}
|
||||
<div className="my-6 flex items-end space-x-2">
|
||||
{currentData?.length === 0 ? (
|
||||
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
|
||||
<p className="text-5xl font-bold">N/A</p>
|
||||
) : (
|
||||
<p className="text-5xl font-bold text-black dark:text-white">
|
||||
{currentData[0]?.user_id || 'N/A'}
|
||||
</p>
|
||||
<p className="text-5xl font-bold">{currentData[0]?.user_id || 'N/A'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dark:text-white">
|
||||
<div>
|
||||
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
|
||||
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`}
|
||||
>
|
||||
<div>
|
||||
<p>{item.user_id}</p>
|
||||
@@ -204,22 +179,20 @@ export default function HomePage() {
|
||||
const userStats = homeResponse?.user_statistics;
|
||||
|
||||
if (homeLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Daily Read Totals Graph */}
|
||||
<div className="w-full">
|
||||
<div className="relative w-full rounded bg-white shadow-lg dark:bg-gray-700">
|
||||
<p className="absolute left-5 top-3 w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
||||
<div className="relative w-full rounded bg-surface shadow-lg">
|
||||
<p className="absolute left-5 top-3 w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||
Daily Read Totals
|
||||
</p>
|
||||
<ReadingHistoryGraph data={graphData || []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<InfoCard title="Documents" size={dbInfo?.documents_size || 0} link="./documents" />
|
||||
<InfoCard title="Activity Records" size={dbInfo?.activity_size || 0} link="./activity" />
|
||||
@@ -227,7 +200,6 @@ export default function HomePage() {
|
||||
<InfoCard title="Devices" size={dbInfo?.devices_size || 0} />
|
||||
</div>
|
||||
|
||||
{/* Streak Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{streaks?.map((streak: UserStreak, index: number) => (
|
||||
<StreakCard
|
||||
@@ -243,7 +215,6 @@ export default function HomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<LeaderboardCard
|
||||
name="WPM"
|
||||
|
||||
190
frontend/src/pages/LoginPage.test.tsx
Normal file
190
frontend/src/pages/LoginPage.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import LoginPage from './LoginPage';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../auth/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ToastContext', () => ({
|
||||
useToasts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../generated/anthoLumeAPIV1', () => ({
|
||||
useGetInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
const mockedUseToasts = vi.mocked(useToasts);
|
||||
const mockedUseGetInfo = vi.mocked(useGetInfo);
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
|
||||
it('submits the username and password to login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loginMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: loginMock,
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginMock).toHaveBeenCalledWith('evan', 'secret');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a toast error when login fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loginMock = vi.fn().mockRejectedValue(new Error('bad credentials'));
|
||||
const showErrorMock = vi.fn();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: loginMock,
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'wrong');
|
||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Invalid credentials');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects when the user is already authenticated', async () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the registration link only when registration is enabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Register here.' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,58 +5,58 @@ import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled =
|
||||
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
|
||||
? infoData.data.registration_enabled
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
interface LoginPageViewProps {
|
||||
username: string;
|
||||
password: string;
|
||||
isLoading: boolean;
|
||||
registrationEnabled: boolean;
|
||||
onUsernameChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
}
|
||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (_err) {
|
||||
showError('Invalid credentials');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
export function getRegistrationEnabled(infoData: unknown): boolean {
|
||||
if (!infoData || typeof infoData !== 'object') {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!('data' in infoData) || !infoData.data || typeof infoData.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!('registration_enabled' in infoData.data) ||
|
||||
typeof infoData.data.registration_enabled !== 'boolean'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return infoData.data.registration_enabled;
|
||||
}
|
||||
|
||||
export function LoginPageView({
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
registrationEnabled,
|
||||
onUsernameChange,
|
||||
onPasswordChange,
|
||||
onSubmit,
|
||||
}: LoginPageViewProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||
<div className="min-h-screen bg-canvas text-content">
|
||||
<div className="flex w-full flex-wrap">
|
||||
<div className="flex w-full flex-col md:w-1/2">
|
||||
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
||||
<p className="text-center text-3xl">Welcome.</p>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
onChange={e => onUsernameChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -68,8 +68,8 @@ export default function LoginPage() {
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
onChange={e => onPasswordChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -103,11 +103,59 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
|
||||
<span className="text-gray-500">AnthoLume</span>
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
|
||||
<span className="text-content-muted">AnthoLume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (_err) {
|
||||
showError('Invalid credentials');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginPageView
|
||||
username={username}
|
||||
password={password}
|
||||
isLoading={isLoading}
|
||||
registrationEnabled={registrationEnabled}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
import type { Progress } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
|
||||
export default function ProgressPage() {
|
||||
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
||||
const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
|
||||
|
||||
const columns = [
|
||||
const columns: Column<Progress>[] = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_value: Progress['document_id'], row: Progress) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
render: (_value, row) => (
|
||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||
</Link>
|
||||
),
|
||||
@@ -23,18 +20,18 @@ export default function ProgressPage() {
|
||||
{
|
||||
key: 'device_name' as const,
|
||||
header: 'Device Name',
|
||||
render: (value: Progress['device_name']) => value || 'Unknown',
|
||||
render: value => String(value || 'Unknown'),
|
||||
},
|
||||
{
|
||||
key: 'percentage' as const,
|
||||
header: 'Percentage',
|
||||
render: (value: Progress['percentage']) => (value ? `${Math.round(value)}%` : '0%'),
|
||||
render: value => (typeof value === 'number' ? `${Math.round(value)}%` : '0%'),
|
||||
},
|
||||
{
|
||||
key: 'created_at' as const,
|
||||
header: 'Created At',
|
||||
render: (value: Progress['created_at']) =>
|
||||
value ? new Date(value).toLocaleDateString() : 'N/A',
|
||||
render: value =>
|
||||
typeof value === 'string' && value ? new Date(value).toLocaleDateString() : 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
302
frontend/src/pages/ReaderPage.tsx
Normal file
302
frontend/src/pages/ReaderPage.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||
import { LoadingState } from '../components/LoadingState';
|
||||
import { CloseIcon } from '../icons';
|
||||
import {
|
||||
getReaderColorScheme,
|
||||
getReaderDevice,
|
||||
getReaderFontFamily,
|
||||
getReaderFontSize,
|
||||
setReaderColorScheme,
|
||||
setReaderFontFamily,
|
||||
setReaderFontSize,
|
||||
type ReaderColorScheme,
|
||||
type ReaderFontFamily,
|
||||
} from '../utils/localSettings';
|
||||
import { useEpubReader } from '../hooks/useEpubReader';
|
||||
|
||||
const colorSchemes: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
||||
const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'];
|
||||
|
||||
export default function ReaderPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
||||
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
|
||||
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
|
||||
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
|
||||
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
|
||||
|
||||
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
||||
|
||||
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '');
|
||||
const { data: progressResponse, isLoading: isProgressLoading } = useGetProgress(id || '', {
|
||||
query: {
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const document = documentResponse?.status === 200 ? documentResponse.data.document : null;
|
||||
const progress = progressResponse?.status === 200 ? progressResponse.data.progress : undefined;
|
||||
|
||||
const deviceId = defaultDeviceId;
|
||||
const deviceName = defaultDeviceName;
|
||||
|
||||
const handleSwipeDown = useCallback(() => {
|
||||
if (isBottomBarOpen) {
|
||||
setIsBottomBarOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!isTopBarOpen) {
|
||||
setIsTopBarOpen(true);
|
||||
}
|
||||
}, [isBottomBarOpen, isTopBarOpen]);
|
||||
|
||||
const handleSwipeUp = useCallback(() => {
|
||||
if (isTopBarOpen) {
|
||||
setIsTopBarOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!isBottomBarOpen) {
|
||||
setIsBottomBarOpen(true);
|
||||
}
|
||||
}, [isBottomBarOpen, isTopBarOpen]);
|
||||
|
||||
const handleCenterTap = useCallback(() => {
|
||||
setIsTopBarOpen(false);
|
||||
setIsBottomBarOpen(false);
|
||||
}, []);
|
||||
|
||||
const reader = useEpubReader({
|
||||
documentId: id || '',
|
||||
initialProgress: progress?.progress,
|
||||
deviceId,
|
||||
deviceName,
|
||||
colorScheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
isPaginationDisabled: useCallback(() => isTopBarOpen || isBottomBarOpen, [isTopBarOpen, isBottomBarOpen]),
|
||||
onSwipeDown: handleSwipeDown,
|
||||
onSwipeUp: handleSwipeUp,
|
||||
onCenterTap: handleCenterTap,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (document?.title) {
|
||||
window.document.title = `AnthoLume - Reader - ${document.title}`;
|
||||
}
|
||||
}, [document?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
reader.setTheme({ colorScheme, fontFamily, fontSize });
|
||||
}, [colorScheme, fontFamily, fontSize, reader.setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTopBarOpen || isBottomBarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = window.document.activeElement;
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
}, [isBottomBarOpen, isTopBarOpen]);
|
||||
|
||||
if (isDocumentLoading || isProgressLoading) {
|
||||
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
|
||||
}
|
||||
|
||||
if (!id || !document || documentResponse?.status !== 200) {
|
||||
return <div className="p-6 text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-canvas text-content">
|
||||
<div className="relative flex h-dvh flex-col overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-x-0 top-0 z-20 border-b border-border bg-surface/95 backdrop-blur transition-transform duration-200 ${
|
||||
isTopBarOpen ? 'translate-y-0' : '-translate-y-full'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Link to={`/documents/${document.id}`} className="block shrink-0">
|
||||
<img
|
||||
className="h-28 w-20 rounded object-cover shadow"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
/>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
|
||||
<p className="truncate text-lg font-semibold text-content">{document.title}</p>
|
||||
<p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p>
|
||||
<p className="truncate text-sm text-content-muted">{document.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/documents/${document.id}`}
|
||||
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsTopBarOpen(false)}
|
||||
className="rounded border border-border p-2 text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
aria-label="Close reader details"
|
||||
>
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{reader.toc.map(item => (
|
||||
<button
|
||||
key={`${item.href}-${item.title}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void reader.goToHref(item.href);
|
||||
setIsTopBarOpen(false);
|
||||
}}
|
||||
className="truncate rounded border border-border bg-surface px-3 py-2 text-left text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 pt-[env(safe-area-inset-top)]">
|
||||
{reader.isLoading && (
|
||||
<LoadingState
|
||||
className="absolute inset-0 z-10 min-h-full bg-canvas"
|
||||
message="Opening book..."
|
||||
/>
|
||||
)}
|
||||
{reader.error ? (
|
||||
<div className="flex h-full items-center justify-center p-6 text-content-muted">
|
||||
{reader.error}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={reader.viewerRef} className="size-full bg-canvas" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 backdrop-blur transition-transform duration-200 ${
|
||||
isBottomBarOpen ? 'translate-y-0' : 'translate-y-full'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-2xl flex-col gap-3 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-content-muted sm:text-sm">
|
||||
<div>
|
||||
<span className="text-content-subtle">Chapter:</span> {reader.stats.chapterName}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-content-subtle">Chapter Pages:</span>{' '}
|
||||
{reader.stats.sectionPage} / {reader.stats.sectionTotalPages}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-content-subtle">Progress:</span>{' '}
|
||||
{reader.stats.percentage.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-surface-strong">
|
||||
<div
|
||||
className="h-full bg-tertiary-500 transition-all"
|
||||
style={{ width: `${reader.stats.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Theme</p>
|
||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{colorSchemes.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setColorSchemeState(option);
|
||||
setReaderColorScheme(option);
|
||||
}}
|
||||
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
|
||||
colorScheme === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
||||
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{fontFamilies.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFontFamilyState(option);
|
||||
setReaderFontFamily(option);
|
||||
}}
|
||||
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
|
||||
fontFamily === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
|
||||
Font Size
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="min-w-12 text-center text-xs text-content sm:text-sm">
|
||||
{fontSize.toFixed(1)}x
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
frontend/src/pages/RegisterPage.test.tsx
Normal file
199
frontend/src/pages/RegisterPage.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import RegisterPage from './RegisterPage';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../auth/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ToastContext', () => ({
|
||||
useToasts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../generated/anthoLumeAPIV1', () => ({
|
||||
useGetInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
const mockedUseToasts = vi.mocked(useToasts);
|
||||
const mockedUseGetInfo = vi.mocked(useGetInfo);
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
|
||||
it('submits the username and password to register', async () => {
|
||||
const user = userEvent.setup();
|
||||
const registerMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: registerMock,
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerMock).toHaveBeenCalledWith('evan', 'secret');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a registration failed toast when registration fails while enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const registerMock = vi.fn().mockRejectedValue(new Error('failed'));
|
||||
const showErrorMock = vi.fn();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: registerMock,
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Registration failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to home when the user is already authenticated', async () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to login when registration is disabled', async () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalledWith('/login', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the form when registration is disabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('Username')).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText('Password')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Register' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export default function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||
<div className="min-h-screen bg-canvas text-content">
|
||||
<div className="flex w-full flex-wrap">
|
||||
<div className="flex w-full flex-col md:w-1/2">
|
||||
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
||||
@@ -61,7 +61,7 @@ export default function RegisterPage() {
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
@@ -74,7 +74,7 @@ export default function RegisterPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
@@ -106,8 +106,8 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
|
||||
<span className="text-gray-500">AnthoLume</span>
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
|
||||
<span className="text-content-muted">AnthoLume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
131
frontend/src/pages/SearchPage.test.tsx
Normal file
131
frontend/src/pages/SearchPage.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, act, fireEvent } from '@testing-library/react';
|
||||
import SearchPage from './SearchPage';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
|
||||
vi.mock('../generated/anthoLumeAPIV1', () => ({
|
||||
useGetSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseGetSearch = vi.mocked(useGetSearch);
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the search disabled until a non-empty query is entered', () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(mockedUseGetSearch).toHaveBeenLastCalledWith(
|
||||
{
|
||||
query: '',
|
||||
source: GetSearchSource.LibGen,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a loading state while results are being fetched', () => {
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no results', () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search results from the generated hook response', () => {
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
author: 'Ursula Le Guin',
|
||||
title: 'A Wizard of Earthsea',
|
||||
series: 'Earthsea',
|
||||
file_type: 'epub',
|
||||
file_size: '1 MB',
|
||||
upload_date: '2025-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('Ursula Le Guin - A Wizard of Earthsea')).toBeInTheDocument();
|
||||
expect(screen.getByText('Earthsea')).toBeInTheDocument();
|
||||
expect(screen.getByText('epub')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the generated hook args after the query debounce and source change', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Query'), { target: { value: 'dune' } });
|
||||
fireEvent.change(screen.getByRole('combobox'), {
|
||||
target: { value: GetSearchSource.Annas_Archive },
|
||||
});
|
||||
|
||||
expect(mockedUseGetSearch).toHaveBeenLastCalledWith(
|
||||
{
|
||||
query: '',
|
||||
source: GetSearchSource.Annas_Archive,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(mockedUseGetSearch).toHaveBeenLastCalledWith(
|
||||
{
|
||||
query: 'dune',
|
||||
source: GetSearchSource.Annas_Archive,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,81 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
import type { SearchItem } from '../generated/model';
|
||||
import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { LoadingState } from '../components';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
interface SearchPageViewProps {
|
||||
query: string;
|
||||
source: GetSearchSource;
|
||||
isLoading: boolean;
|
||||
results: SearchItem[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onSourceChange: (value: GetSearchSource) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
const { data, isLoading } = useGetSearch({ query, source });
|
||||
const results = data?.status === 200 ? data.data.results : [];
|
||||
export function getSearchResults(data: unknown): SearchItem[] {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Trigger refetch by updating query
|
||||
};
|
||||
if (!('status' in data) || data.status !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!('data' in data) || !data.data || typeof data.data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!('results' in data.data) || !Array.isArray(data.data.results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.data.results as SearchItem[];
|
||||
}
|
||||
|
||||
export function SearchPageView({
|
||||
query,
|
||||
source,
|
||||
isLoading,
|
||||
results,
|
||||
onQueryChange,
|
||||
onSourceChange,
|
||||
onSubmit,
|
||||
}: SearchPageViewProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||
<div className="flex grow flex-col gap-4">
|
||||
{/* Search Form */}
|
||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<SearchIcon size={15} />
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
onChange={e => onQueryChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Query"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-w-[12em]">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<BookIcon size={15} />
|
||||
</span>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => setSource(e.target.value as GetSearchSource)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
>
|
||||
<option value="LibGen">Library Genesis</option>
|
||||
<option value="Annas Archive">Annas Archive</option>
|
||||
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
@@ -58,38 +86,37 @@ export default function SearchPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Search Results Table */}
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-white text-sm leading-normal md:text-sm dark:bg-gray-700">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content md:text-sm">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"></th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase"></th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Document
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Series
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Size
|
||||
</th>
|
||||
<th className="hidden border-b border-gray-200 p-3 text-left font-normal uppercase md:block dark:border-gray-800">
|
||||
<th className="hidden border-b border-border p-3 text-left font-normal uppercase md:table-cell">
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={6}>
|
||||
Loading...
|
||||
<LoadingState />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && !results && (
|
||||
{!isLoading && results.length === 0 && (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={6}>
|
||||
No Results
|
||||
@@ -97,27 +124,26 @@ export default function SearchPage() {
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading &&
|
||||
results &&
|
||||
results.map((item: SearchItem) => (
|
||||
results.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
|
||||
<button className="hover:text-purple-600" title="Download">
|
||||
<td className="border-b border-border p-3 text-content-muted">
|
||||
<button className="hover:text-primary-600" title="Download">
|
||||
<DownloadIcon size={15} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
{item.author || 'N/A'} - {item.title || 'N/A'}
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{item.series || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{item.file_type || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{item.file_size || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="hidden border-b border-gray-200 p-3 md:table-cell">
|
||||
<td className="hidden border-b border-border p-3 md:table-cell">
|
||||
<p>{item.upload_date || 'N/A'}</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -129,3 +155,41 @@ export default function SearchPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeQuery, setActiveQuery] = useState('');
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveQuery(debouncedQuery);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
const { data, isLoading } = useGetSearch(
|
||||
{ query: activeQuery, source },
|
||||
{
|
||||
query: {
|
||||
enabled: activeQuery.trim().length > 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
const results = getSearchResults(data);
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setActiveQuery(query.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchPageView
|
||||
query={query}
|
||||
source={source}
|
||||
isLoading={isLoading}
|
||||
results={results}
|
||||
onQueryChange={setQuery}
|
||||
onSourceChange={setSource}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,21 @@ import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
|
||||
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
|
||||
{ value: 'light', label: 'Light', description: 'Always use the light palette.' },
|
||||
{ value: 'dark', label: 'Dark', description: 'Always use the dark palette.' },
|
||||
{ value: 'system', label: 'System', description: 'Follow your device preference.' },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
|
||||
const { showInfo, showError } = useToasts();
|
||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
@@ -31,15 +40,21 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSettings.mutateAsync({
|
||||
const response = await updateSettings.mutateAsync({
|
||||
data: {
|
||||
password: password,
|
||||
password,
|
||||
new_password: newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Password updated successfully');
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
return;
|
||||
}
|
||||
|
||||
showError('Failed to update password: ' + getErrorMessage(response.data));
|
||||
} catch (error) {
|
||||
showError('Failed to update password: ' + getErrorMessage(error));
|
||||
}
|
||||
@@ -49,12 +64,18 @@ export default function SettingsPage() {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await updateSettings.mutateAsync({
|
||||
const response = await updateSettings.mutateAsync({
|
||||
data: {
|
||||
timezone: timezone,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Timezone updated successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
showError('Failed to update timezone: ' + getErrorMessage(response.data));
|
||||
} catch (error) {
|
||||
showError('Failed to update timezone: ' + getErrorMessage(error));
|
||||
}
|
||||
@@ -64,35 +85,43 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-white p-4 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700">
|
||||
<div className="mb-4 size-16 rounded-full bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-6 w-32 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="flex flex-col items-center rounded bg-surface p-4 shadow-lg md:w-60 lg:w-80">
|
||||
<div className="mb-4 size-16 rounded-full bg-surface-strong" />
|
||||
<div className="h-6 w-32 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||
<div className="flex gap-4">
|
||||
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-10 w-40 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-10 w-40 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||
<div className="flex gap-4">
|
||||
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-10 w-40 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-10 w-40 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||
<div className="mb-4 h-6 w-24 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{themeModes.map(mode => (
|
||||
<div key={mode.value} className="h-24 rounded bg-surface-strong" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-24 rounded bg-surface-strong" />
|
||||
<div className="mb-4 flex gap-4">
|
||||
<div className="h-6 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-6 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-6 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||
</div>
|
||||
<div className="h-32 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
||||
<div className="h-32 flex-1 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,43 +130,41 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||
{/* User Profile Card */}
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-white p-4 text-gray-500 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700 dark:text-white">
|
||||
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
|
||||
<UserIcon size={60} />
|
||||
<p className="text-lg">{settingsData?.user.username || 'N/A'}</p>
|
||||
<p className="text-lg text-content">{settingsData?.user.username || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-4">
|
||||
{/* Change Password Form */}
|
||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<p className="mb-2 text-lg font-semibold">Change Password</p>
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<PasswordIcon size={15} />
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<PasswordIcon size={15} />
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="New Password"
|
||||
/>
|
||||
</div>
|
||||
@@ -150,18 +177,56 @@ export default function SettingsPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Change Timezone Form */}
|
||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<p className="mb-2 text-lg font-semibold">Change Timezone</p>
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
|
||||
<p>
|
||||
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{themeModes.map(mode => {
|
||||
const isSelected = themeMode === mode.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode.value)}
|
||||
className={`rounded border p-4 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
|
||||
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-content">{mode.label}</span>
|
||||
<span
|
||||
className={`inline-flex size-4 rounded-full border ${
|
||||
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">{mode.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
|
||||
<div className="relative flex grow">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<ClockIcon size={15} />
|
||||
</span>
|
||||
<select
|
||||
value={timezone || 'UTC'}
|
||||
onChange={e => setTimezone(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
@@ -183,24 +248,23 @@ export default function SettingsPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Devices Table */}
|
||||
<div className="flex grow flex-col rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<p className="text-lg font-semibold">Devices</p>
|
||||
<table className="min-w-full bg-white text-sm dark:bg-gray-700">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Devices</p>
|
||||
<table className="min-w-full bg-surface text-sm">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="border-b border-gray-200 p-3 pl-0 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 pl-0 text-left font-normal uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Last Sync
|
||||
</th>
|
||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
<tbody className="text-content">
|
||||
{!settingsData?.devices || settingsData.devices.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={3}>
|
||||
|
||||
77
frontend/src/test/renderWithProviders.tsx
Normal file
77
frontend/src/test/renderWithProviders.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/ToastContext';
|
||||
|
||||
interface RenderWithProvidersOptions {
|
||||
route?: string;
|
||||
queryClient?: QueryClient;
|
||||
withQueryClient?: boolean;
|
||||
withToastProvider?: boolean;
|
||||
}
|
||||
|
||||
interface RenderWithProvidersWrapperProps {
|
||||
children: ReactNode;
|
||||
route: string;
|
||||
queryClient: QueryClient;
|
||||
withQueryClient: boolean;
|
||||
withToastProvider: boolean;
|
||||
}
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function RenderWithProvidersWrapper({
|
||||
children,
|
||||
route,
|
||||
queryClient,
|
||||
withQueryClient,
|
||||
withToastProvider,
|
||||
}: RenderWithProvidersWrapperProps) {
|
||||
let content = <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>;
|
||||
|
||||
if (withQueryClient) {
|
||||
content = <QueryClientProvider client={queryClient}>{content}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
if (withToastProvider) {
|
||||
content = <ToastProvider>{content}</ToastProvider>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{
|
||||
route = '/',
|
||||
queryClient = createTestQueryClient(),
|
||||
withQueryClient = true,
|
||||
withToastProvider = false,
|
||||
}: RenderWithProvidersOptions = {}
|
||||
) {
|
||||
return {
|
||||
ui,
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<RenderWithProvidersWrapper
|
||||
route={route}
|
||||
queryClient={queryClient}
|
||||
withQueryClient={withQueryClient}
|
||||
withToastProvider={withToastProvider}
|
||||
>
|
||||
{children}
|
||||
</RenderWithProvidersWrapper>
|
||||
),
|
||||
queryClient,
|
||||
};
|
||||
}
|
||||
7
frontend/src/test/setup.ts
Normal file
7
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
126
frontend/src/theme/ThemeProvider.tsx
Normal file
126
frontend/src/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { getThemeMode, setThemeMode, type ThemeMode } from '../utils/localSettings';
|
||||
|
||||
export type ResolvedThemeMode = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextValue {
|
||||
themeMode: ThemeMode;
|
||||
resolvedThemeMode: ResolvedThemeMode;
|
||||
setThemeMode: (themeMode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function getSystemThemeMode(): ResolvedThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function resolveThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
|
||||
return themeMode === 'system' ? getSystemThemeMode() : themeMode;
|
||||
}
|
||||
|
||||
export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
|
||||
const resolvedThemeMode = resolveThemeMode(themeMode);
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.toggle('dark', resolvedThemeMode === 'dark');
|
||||
document.documentElement.dataset.themeMode = themeMode;
|
||||
document.documentElement.style.colorScheme = resolvedThemeMode;
|
||||
}
|
||||
|
||||
return resolvedThemeMode;
|
||||
}
|
||||
|
||||
export function initializeThemeMode(): ResolvedThemeMode {
|
||||
return applyThemeMode(getThemeMode());
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
|
||||
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
|
||||
resolveThemeMode(getThemeMode())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedThemeMode(applyThemeMode(themeModeState));
|
||||
}, [themeModeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
if (themeModeState === 'system') {
|
||||
setResolvedThemeMode(applyThemeMode('system'));
|
||||
}
|
||||
};
|
||||
|
||||
mediaQueryList.addEventListener('change', handleSystemThemeChange);
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
||||
};
|
||||
}, [themeModeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== 'antholume:settings') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextThemeMode = getThemeMode();
|
||||
setThemeModeState(nextThemeMode);
|
||||
setResolvedThemeMode(applyThemeMode(nextThemeMode));
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
|
||||
setThemeMode(nextThemeMode);
|
||||
setThemeModeState(nextThemeMode);
|
||||
setResolvedThemeMode(applyThemeMode(nextThemeMode));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
themeMode: themeModeState,
|
||||
resolvedThemeMode,
|
||||
setThemeMode: updateThemeMode,
|
||||
}),
|
||||
[resolvedThemeMode, themeModeState, updateThemeMode]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
4
frontend/src/types/epubjs.d.ts
vendored
Normal file
4
frontend/src/types/epubjs.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'epubjs' {
|
||||
const ePub: (...args: unknown[]) => unknown;
|
||||
export default ePub;
|
||||
}
|
||||
22
frontend/src/types/window.d.ts
vendored
Normal file
22
frontend/src/types/window.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
interface FilePickerAcceptType {
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface SaveFilePickerOptions {
|
||||
suggestedName?: string;
|
||||
types?: FilePickerAcceptType[];
|
||||
}
|
||||
|
||||
interface FileSystemWritableFileStream {
|
||||
write(data: BufferSource | Blob | string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface FileSystemFileHandle {
|
||||
createWritable(): Promise<FileSystemWritableFileStream>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
showSaveFilePicker?: (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
|
||||
}
|
||||
48
frontend/src/utils/errors.test.ts
Normal file
48
frontend/src/utils/errors.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getErrorMessage } from './errors';
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('returns Error.message for Error instances', () => {
|
||||
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
||||
});
|
||||
|
||||
it('prefers response.data.message over top-level message', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
response: {
|
||||
data: {
|
||||
message: 'Response message',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBe('Response message');
|
||||
});
|
||||
|
||||
it('falls back to top-level message when response.data.message is unavailable', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
})
|
||||
).toBe('Top-level message');
|
||||
});
|
||||
|
||||
it('uses the fallback for null, empty, and unknown values', () => {
|
||||
expect(getErrorMessage(null, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message');
|
||||
expect(
|
||||
getErrorMessage(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
message: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Fallback message'
|
||||
)
|
||||
).toBe('Fallback message');
|
||||
});
|
||||
});
|
||||
@@ -34,12 +34,6 @@ describe('formatNumber', () => {
|
||||
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||
});
|
||||
|
||||
it('matches Go test cases exactly', () => {
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
expect(formatNumber(19823)).toBe('19.8k');
|
||||
expect(formatNumber(1500000)).toBe('1.50M');
|
||||
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
@@ -68,9 +62,4 @@ describe('formatDuration', () => {
|
||||
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
||||
});
|
||||
|
||||
it('matches Go test cases exactly', () => {
|
||||
expect(formatDuration(0)).toBe('N/A');
|
||||
expect(formatDuration(22 * 24 * 60 * 60 + 7 * 60 * 60 + 39 * 60 + 31)).toBe('22d 7h 39m 31s');
|
||||
expect(formatDuration(5 * 60 + 15)).toBe('5m 15s');
|
||||
});
|
||||
});
|
||||
|
||||
147
frontend/src/utils/localSettings.ts
Normal file
147
frontend/src/utils/localSettings.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type DocumentsViewMode = 'grid' | 'list';
|
||||
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
|
||||
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
||||
|
||||
const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||
|
||||
interface LocalSettings {
|
||||
themeMode?: ThemeMode;
|
||||
documentsViewMode?: DocumentsViewMode;
|
||||
readerColorScheme?: ReaderColorScheme;
|
||||
readerFontFamily?: ReaderFontFamily;
|
||||
readerFontSize?: number;
|
||||
readerDeviceId?: string;
|
||||
readerDeviceName?: string;
|
||||
}
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function readLocalSettings(): LocalSettings {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawValue = window.localStorage.getItem(LOCAL_SETTINGS_KEY);
|
||||
if (!rawValue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedValue = JSON.parse(rawValue);
|
||||
return typeof parsedValue === 'object' && parsedValue !== null ? parsedValue : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalSettings(settings: LocalSettings): void {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function updateLocalSettings(partialSettings: LocalSettings): void {
|
||||
writeLocalSettings({
|
||||
...readLocalSettings(),
|
||||
...partialSettings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getThemeMode(): ThemeMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.themeMode === 'light' || settings.themeMode === 'dark'
|
||||
? settings.themeMode
|
||||
: 'system';
|
||||
}
|
||||
|
||||
export function setThemeMode(themeMode: ThemeMode): void {
|
||||
updateLocalSettings({ themeMode });
|
||||
}
|
||||
|
||||
export function getDocumentsViewMode(): DocumentsViewMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.documentsViewMode === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
export function setDocumentsViewMode(documentsViewMode: DocumentsViewMode): void {
|
||||
updateLocalSettings({ documentsViewMode });
|
||||
}
|
||||
|
||||
export function getReaderColorScheme(): ReaderColorScheme {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerColorScheme) {
|
||||
case 'light':
|
||||
case 'tan':
|
||||
case 'blue':
|
||||
case 'gray':
|
||||
case 'black':
|
||||
return settings.readerColorScheme;
|
||||
default:
|
||||
return 'tan';
|
||||
}
|
||||
}
|
||||
|
||||
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
|
||||
updateLocalSettings({ readerColorScheme });
|
||||
}
|
||||
|
||||
export function getReaderFontFamily(): ReaderFontFamily {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerFontFamily) {
|
||||
case 'Serif':
|
||||
case 'Open Sans':
|
||||
case 'Arbutus Slab':
|
||||
case 'Lato':
|
||||
return settings.readerFontFamily;
|
||||
default:
|
||||
return 'Serif';
|
||||
}
|
||||
}
|
||||
|
||||
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
|
||||
updateLocalSettings({ readerFontFamily });
|
||||
}
|
||||
|
||||
export function getReaderFontSize(): number {
|
||||
const settings = readLocalSettings();
|
||||
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
|
||||
? settings.readerFontSize
|
||||
: 1;
|
||||
}
|
||||
|
||||
export function setReaderFontSize(readerFontSize: number): void {
|
||||
updateLocalSettings({ readerFontSize });
|
||||
}
|
||||
|
||||
export function getReaderDevice(): { id: string; name: string } {
|
||||
const settings = readLocalSettings();
|
||||
const id =
|
||||
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
|
||||
? settings.readerDeviceId
|
||||
: crypto.randomUUID();
|
||||
const name =
|
||||
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
|
||||
? settings.readerDeviceName
|
||||
: 'Web Reader';
|
||||
|
||||
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id,
|
||||
readerDeviceName: name,
|
||||
});
|
||||
}
|
||||
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
export function setReaderDevice(name: string, id?: string): void {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id ?? crypto.randomUUID(),
|
||||
readerDeviceName: name,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,51 @@
|
||||
const withOpacity = cssVariable => `rgb(var(${cssVariable}) / <alpha-value>)`;
|
||||
|
||||
const buildScale = scaleName => ({
|
||||
50: withOpacity(`--${scaleName}-50`),
|
||||
100: withOpacity(`--${scaleName}-100`),
|
||||
200: withOpacity(`--${scaleName}-200`),
|
||||
300: withOpacity(`--${scaleName}-300`),
|
||||
400: withOpacity(`--${scaleName}-400`),
|
||||
500: withOpacity(`--${scaleName}-500`),
|
||||
600: withOpacity(`--${scaleName}-600`),
|
||||
700: withOpacity(`--${scaleName}-700`),
|
||||
800: withOpacity(`--${scaleName}-800`),
|
||||
900: withOpacity(`--${scaleName}-900`),
|
||||
DEFAULT: withOpacity(`--${scaleName}-500`),
|
||||
foreground: withOpacity(`--${scaleName}-foreground`),
|
||||
});
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'media',
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: withOpacity('--canvas'),
|
||||
surface: withOpacity('--surface'),
|
||||
'surface-muted': withOpacity('--surface-muted'),
|
||||
'surface-strong': withOpacity('--surface-strong'),
|
||||
overlay: withOpacity('--overlay'),
|
||||
content: withOpacity('--content'),
|
||||
'content-muted': withOpacity('--content-muted'),
|
||||
'content-subtle': withOpacity('--content-subtle'),
|
||||
'content-inverse': withOpacity('--content-inverse'),
|
||||
border: withOpacity('--border'),
|
||||
'border-muted': withOpacity('--border-muted'),
|
||||
'border-strong': withOpacity('--border-strong'),
|
||||
white: withOpacity('--white'),
|
||||
black: withOpacity('--black'),
|
||||
gray: buildScale('neutral'),
|
||||
purple: buildScale('primary'),
|
||||
blue: buildScale('secondary'),
|
||||
yellow: buildScale('warning'),
|
||||
red: buildScale('error'),
|
||||
primary: buildScale('primary'),
|
||||
secondary: buildScale('secondary'),
|
||||
tertiary: buildScale('tertiary'),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -22,4 +22,8 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user