11 Commits

Author SHA1 Message Date
75c872264f chore: remove unnecessary crap ai added
Some checks failed
continuous-integration/drone/pr Build is failing
2026-04-03 19:46:05 -04:00
0930054847 more reader
Some checks failed
continuous-integration/drone/pr Build is failing
2026-04-03 13:45:17 -04:00
aa812c6917 wip reader migration 2026-04-03 12:15:48 -04:00
8ec3349b7c chore(api): update to allow CRUD progress and activity in v1 2026-04-03 10:37:50 -04:00
decc3f0195 fix: toast theme & error msgs 2026-04-03 10:08:13 -04:00
b13f9b362c theme draft 2 (done?) 2026-03-22 17:21:34 -04:00
6c2c4f6b8b remove dumb auth 2026-03-22 17:21:34 -04:00
d38392ac9a theme draft 1 2026-03-22 17:21:34 -04:00
63ad73755d wip 22 2026-03-22 17:21:34 -04:00
784e53c557 wip 21 2026-03-22 17:21:34 -04:00
9ed63b2695 wip 20 2026-03-22 17:21:34 -04:00
89 changed files with 5946 additions and 1411 deletions

View File

@@ -1,31 +1,75 @@
# AnthoLume - Agent Context # AnthoLume Agent Guide
## Critical Rules ## 1) Working Style
### Generated Files - Keep changes targeted.
- **NEVER edit generated files directly** - Always edit the source and regenerate - Do not refactor broadly unless the task requires it.
- Go backend API: Edit `api/v1/openapi.yaml` then run: - Validate only what is relevant to the change when practical.
- `go generate ./api/v1/generate.go` - If a fix will require substantial refactoring or wide-reaching changes, stop and ask first.
- `cd frontend && bun run generate:api`
- Examples of generated files:
- `api/v1/api.gen.go`
- `frontend/src/generated/**/*.ts`
### Database Access ## 2) Hard Rules
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
- Define queries in `database/query.sql` and regenerate via `sqlc generate`
### Error Handling - Never edit generated files directly.
- Use `fmt.Errorf("message: %w", err)` for wrapping errors - Never write ad-hoc SQL.
- Do NOT use `github.com/pkg/errors` - For Go error wrapping, use `fmt.Errorf("message: %w", err)`.
- Do not use `github.com/pkg/errors`.
## Frontend ## 3) Generated Code
- **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`
## Regeneration ### OpenAPI
- Go backend: `go generate ./api/v1/generate.go` Edit:
- TS client: `cd frontend && bun run generate:api` - `api/v1/openapi.yaml`
Regenerate:
- `go generate ./api/v1/generate.go`
- `cd frontend && bun run generate:api`
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`
### SQLC
Edit:
- `database/query.sql`
Regenerate:
- `sqlc generate`
## 4) Backend / Assets
### 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.

View File

@@ -2,7 +2,9 @@ package v1
import ( import (
"context" "context"
"time"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
) )
@@ -72,3 +74,78 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
} }
return GetActivity200JSONResponse(response), nil 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
}

View File

@@ -438,11 +438,10 @@ func (s *Server) GetUsers(ctx context.Context, request GetUsersRequestObject) (G
apiUsers := make([]User, len(users)) apiUsers := make([]User, len(users))
for i, user := range users { for i, user := range users {
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
apiUsers[i] = User{ apiUsers[i] = User{
Id: user.ID, Id: user.ID,
Admin: user.Admin, 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)) apiUsers := make([]User, len(users))
for i, user := range users { for i, user := range users {
createdAt, _ := time.Parse("2006-01-02T15:04:05", user.CreatedAt)
apiUsers[i] = User{ apiUsers[i] = User{
Id: user.ID, Id: user.ID,
Admin: user.Admin, 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 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 := "" filter := ""
if request.Params.Filter != nil { if request.Params.Filter != nil {
filter = strings.TrimSpace(*request.Params.Filter) 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") logPath := filepath.Join(s.cfg.ConfigPath, "logs/antholume.log")
logFile, err := os.Open(logPath) logFile, err := os.Open(logPath)
if err != nil { if err != nil {
@@ -975,58 +981,90 @@ func (s *Server) GetLogs(ctx context.Context, request GetLogsRequestObject) (Get
} }
defer logFile.Close() defer logFile.Close()
// Log Lines (mirroring legacy) offset := (page - 1) * limit
var logLines []string logLines := make([]string, 0, limit)
matchedCount := int64(0)
scanner := bufio.NewScanner(logFile) scanner := bufio.NewScanner(logFile)
for scanner.Scan() { for scanner.Scan() {
rawLog := scanner.Text() formattedLog, matched := formatLogLine(scanner.Text(), basicFilter, jqFilter)
if !matched {
// Attempt JSON Pretty (mirroring legacy)
var jsonMap map[string]any
err := json.Unmarshal([]byte(rawLog), &jsonMap)
if err != nil {
logLines = append(logLines, rawLog)
continue continue
} }
// Parse JSON (mirroring legacy) if matchedCount >= offset && int64(len(logLines)) < limit {
rawData, err := json.MarshalIndent(jsonMap, "", " ") logLines = append(logLines, formattedLog)
if err != nil { }
logLines = append(logLines, rawLog) matchedCount++
continue
} }
// Basic Filter (mirroring legacy) if err := scanner.Err(); err != nil {
if basicFilter != "" && strings.Contains(string(rawData), basicFilter) { return GetLogs500JSONResponse{Code: 500, Message: "Unable to read AnthoLume log file"}, nil
logLines = append(logLines, string(rawData))
continue
} }
// No JQ Filter (mirroring legacy) var nextPage *int64
if jqFilter == nil { var previousPage *int64
continue if page > 1 {
previousPage = ptrOf(page - 1)
} }
if offset+int64(len(logLines)) < matchedCount {
// Error or nil (mirroring legacy) nextPage = ptrOf(page + 1)
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))
} }
return GetLogs200JSONResponse{ return GetLogs200JSONResponse{
Logs: &logLines, Logs: &logLines,
Filter: &filter, Filter: &filter,
Page: &page,
Limit: &limit,
NextPage: nextPage,
PreviousPage: previousPage,
Total: &matchedCount,
}, nil }, 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
View 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)
}

View File

@@ -164,6 +164,27 @@ type ActivityResponse struct {
// BackupType defines model for BackupType. // BackupType defines model for BackupType.
type BackupType string 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. // DatabaseInfo defines model for DatabaseInfo.
type DatabaseInfo struct { type DatabaseInfo struct {
ActivitySize int64 `json:"activity_size"` ActivitySize int64 `json:"activity_size"`
@@ -215,7 +236,6 @@ type Document struct {
// DocumentResponse defines model for DocumentResponse. // DocumentResponse defines model for DocumentResponse.
type DocumentResponse struct { type DocumentResponse struct {
Document Document `json:"document"` Document Document `json:"document"`
Progress *Progress `json:"progress,omitempty"`
} }
// DocumentsResponse defines model for DocumentsResponse. // DocumentsResponse defines model for DocumentsResponse.
@@ -227,8 +247,6 @@ type DocumentsResponse struct {
PreviousPage *int64 `json:"previous_page,omitempty"` PreviousPage *int64 `json:"previous_page,omitempty"`
Search *string `json:"search,omitempty"` Search *string `json:"search,omitempty"`
Total int64 `json:"total"` Total int64 `json:"total"`
User UserData `json:"user"`
WordCounts []WordCount `json:"word_counts"`
} }
// ErrorResponse defines model for ErrorResponse. // ErrorResponse defines model for ErrorResponse.
@@ -315,7 +333,12 @@ type LoginResponse struct {
// LogsResponse defines model for LogsResponse. // LogsResponse defines model for LogsResponse.
type LogsResponse struct { type LogsResponse struct {
Filter *string `json:"filter,omitempty"` Filter *string `json:"filter,omitempty"`
Limit *int64 `json:"limit,omitempty"`
Logs *[]LogEntry `json:"logs,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. // MessageResponse defines model for MessageResponse.
@@ -330,9 +353,11 @@ type OperationType string
type Progress struct { type Progress struct {
Author *string `json:"author,omitempty"` Author *string `json:"author,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"`
DeviceId *string `json:"device_id,omitempty"`
DeviceName *string `json:"device_name,omitempty"` DeviceName *string `json:"device_name,omitempty"`
DocumentId *string `json:"document_id,omitempty"` DocumentId *string `json:"document_id,omitempty"`
Percentage *float64 `json:"percentage,omitempty"` Percentage *float64 `json:"percentage,omitempty"`
Progress *string `json:"progress,omitempty"`
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
UserId *string `json:"user_id,omitempty"` UserId *string `json:"user_id,omitempty"`
} }
@@ -383,6 +408,21 @@ type StreaksResponse struct {
Streaks []UserStreak `json:"streaks"` 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. // UpdateSettingsRequest defines model for UpdateSettingsRequest.
type UpdateSettingsRequest struct { type UpdateSettingsRequest struct {
NewPassword *string `json:"new_password,omitempty"` NewPassword *string `json:"new_password,omitempty"`
@@ -426,12 +466,6 @@ type UsersResponse struct {
Users *[]User `json:"users,omitempty"` 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. // GetActivityParams defines parameters for GetActivity.
type GetActivityParams struct { type GetActivityParams struct {
DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"` DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"`
@@ -465,6 +499,8 @@ type PostImportFormdataBody struct {
// GetLogsParams defines parameters for GetLogs. // GetLogsParams defines parameters for GetLogs.
type GetLogsParams struct { type GetLogsParams struct {
Filter *string `form:"filter,omitempty" json:"filter,omitempty"` 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. // UpdateUserFormdataBody defines parameters for UpdateUser.
@@ -526,6 +562,9 @@ type PostSearchFormdataBody struct {
Title string `form:"title" json:"title"` 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. // PostAdminActionMultipartRequestBody defines body for PostAdminAction for multipart/form-data ContentType.
type PostAdminActionMultipartRequestBody PostAdminActionMultipartBody type PostAdminActionMultipartRequestBody PostAdminActionMultipartBody
@@ -550,6 +589,9 @@ type EditDocumentJSONRequestBody EditDocumentJSONBody
// UploadDocumentCoverMultipartRequestBody defines body for UploadDocumentCover for multipart/form-data ContentType. // UploadDocumentCoverMultipartRequestBody defines body for UploadDocumentCover for multipart/form-data ContentType.
type UploadDocumentCoverMultipartRequestBody UploadDocumentCoverMultipartBody 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. // PostSearchFormdataRequestBody defines body for PostSearch for application/x-www-form-urlencoded ContentType.
type PostSearchFormdataRequestBody PostSearchFormdataBody type PostSearchFormdataRequestBody PostSearchFormdataBody
@@ -561,6 +603,9 @@ type ServerInterface interface {
// Get activity data // Get activity data
// (GET /activity) // (GET /activity)
GetActivity(w http.ResponseWriter, r *http.Request, params GetActivityParams) 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 page data
// (GET /admin) // (GET /admin)
GetAdmin(w http.ResponseWriter, r *http.Request) GetAdmin(w http.ResponseWriter, r *http.Request)
@@ -636,6 +681,9 @@ type ServerInterface interface {
// List progress records // List progress records
// (GET /progress) // (GET /progress)
GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams) 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 document progress
// (GET /progress/{id}) // (GET /progress/{id})
GetProgress(w http.ResponseWriter, r *http.Request, id string) 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) 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 // GetAdmin operation middleware
func (siw *ServerInterfaceWrapper) GetAdmin(w http.ResponseWriter, r *http.Request) { 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 return
} }
// ------------- Optional query parameter "page" -------------
err = runtime.BindQueryParameterWithOptions("form", true, false, "page", r.URL.Query(), &params.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(), &params.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) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetLogs(w, r, params) siw.Handler.GetLogs(w, r, params)
})) }))
@@ -1348,6 +1432,26 @@ func (siw *ServerInterfaceWrapper) GetProgressList(w http.ResponseWriter, r *htt
handler.ServeHTTP(w, r) 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 // GetProgress operation middleware
func (siw *ServerInterfaceWrapper) GetProgress(w http.ResponseWriter, r *http.Request) { 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("GET "+options.BaseURL+"/activity", wrapper.GetActivity)
m.HandleFunc("POST "+options.BaseURL+"/activity", wrapper.CreateActivity)
m.HandleFunc("GET "+options.BaseURL+"/admin", wrapper.GetAdmin) m.HandleFunc("GET "+options.BaseURL+"/admin", wrapper.GetAdmin)
m.HandleFunc("POST "+options.BaseURL+"/admin", wrapper.PostAdminAction) m.HandleFunc("POST "+options.BaseURL+"/admin", wrapper.PostAdminAction)
m.HandleFunc("GET "+options.BaseURL+"/admin/import", wrapper.GetImportDirectory) 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+"/home/streaks", wrapper.GetStreaks)
m.HandleFunc("GET "+options.BaseURL+"/info", wrapper.GetInfo) m.HandleFunc("GET "+options.BaseURL+"/info", wrapper.GetInfo)
m.HandleFunc("GET "+options.BaseURL+"/progress", wrapper.GetProgressList) 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+"/progress/{id}", wrapper.GetProgress)
m.HandleFunc("GET "+options.BaseURL+"/search", wrapper.GetSearch) m.HandleFunc("GET "+options.BaseURL+"/search", wrapper.GetSearch)
m.HandleFunc("POST "+options.BaseURL+"/search", wrapper.PostSearch) 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) 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 { type GetAdminRequestObject struct {
} }
@@ -2008,13 +2158,21 @@ type LoginResponseObject interface {
VisitLoginResponse(w http.ResponseWriter) error 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 { func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
w.WriteHeader(200) w.WriteHeader(200)
return json.NewEncoder(w).Encode(response) return json.NewEncoder(w).Encode(response.Body)
} }
type Login400JSONResponse ErrorResponse type Login400JSONResponse ErrorResponse
@@ -2101,13 +2259,21 @@ type RegisterResponseObject interface {
VisitRegisterResponse(w http.ResponseWriter) error 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 { func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
w.WriteHeader(201) w.WriteHeader(201)
return json.NewEncoder(w).Encode(response) return json.NewEncoder(w).Encode(response.Body)
} }
type Register400JSONResponse ErrorResponse type Register400JSONResponse ErrorResponse
@@ -2691,6 +2857,50 @@ func (response GetProgressList500JSONResponse) VisitGetProgressListResponse(w ht
return json.NewEncoder(w).Encode(response) 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 { type GetProgressRequestObject struct {
Id string `json:"id"` Id string `json:"id"`
} }
@@ -2896,6 +3106,9 @@ type StrictServerInterface interface {
// Get activity data // Get activity data
// (GET /activity) // (GET /activity)
GetActivity(ctx context.Context, request GetActivityRequestObject) (GetActivityResponseObject, error) 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 page data
// (GET /admin) // (GET /admin)
GetAdmin(ctx context.Context, request GetAdminRequestObject) (GetAdminResponseObject, error) GetAdmin(ctx context.Context, request GetAdminRequestObject) (GetAdminResponseObject, error)
@@ -2971,6 +3184,9 @@ type StrictServerInterface interface {
// List progress records // List progress records
// (GET /progress) // (GET /progress)
GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error) 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 document progress
// (GET /progress/{id}) // (GET /progress/{id})
GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) 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 // GetAdmin operation middleware
func (sh *strictHandler) GetAdmin(w http.ResponseWriter, r *http.Request) { func (sh *strictHandler) GetAdmin(w http.ResponseWriter, r *http.Request) {
var request GetAdminRequestObject 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 // GetProgress operation middleware
func (sh *strictHandler) GetProgress(w http.ResponseWriter, r *http.Request, id string) { func (sh *strictHandler) GetProgress(w http.ResponseWriter, r *http.Request, id string) {
var request GetProgressRequestObject var request GetProgressRequestObject

View File

@@ -41,8 +41,13 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
} }
return Login200JSONResponse{ return Login200JSONResponse{
Body: LoginResponse{
Username: user.ID, Username: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
},
Headers: Login200ResponseHeaders{
SetCookie: s.getSetCookieFromContext(ctx),
},
}, nil }, nil
} }
@@ -81,8 +86,13 @@ func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (R
} }
return Register201JSONResponse{ return Register201JSONResponse{
Body: LoginResponse{
Username: user.ID, Username: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
},
Headers: Register201ResponseHeaders{
SetCookie: s.getSetCookieFromContext(ctx),
},
}, nil }, nil
} }
@@ -207,6 +217,14 @@ func (s *Server) getResponseWriterFromContext(ctx context.Context) http.Response
return w 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 // getSession retrieves auth data from the session cookie
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) { func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
// Get session from cookie store // Get session from cookie store

View File

@@ -66,6 +66,13 @@ func (suite *AuthTestSuite) createTestUser(username, password string) {
suite.Require().NoError(err) 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 { func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
reqBody := LoginRequest{ reqBody := LoginRequest{
Username: username, Username: username,
@@ -86,6 +93,7 @@ func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
cookies := w.Result().Cookies() cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1, "should have session cookie") suite.Require().Len(cookies, 1, "should have session cookie")
suite.assertSessionCookie(cookies[0])
return cookies[0] return cookies[0]
} }
@@ -109,6 +117,10 @@ func (suite *AuthTestSuite) TestAPILogin() {
var resp LoginResponse var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username) suite.Equal("testuser", resp.Username)
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1)
suite.assertSessionCookie(cookies[0])
} }
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() { 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") suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
cookies := w.Result().Cookies() 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") user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
suite.Require().NoError(err) suite.Require().NoError(err)
@@ -182,6 +195,10 @@ func (suite *AuthTestSuite) TestAPILogout() {
suite.srv.ServeHTTP(w, req) suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code) 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() { func (suite *AuthTestSuite) TestAPIGetMe() {

View File

@@ -63,7 +63,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
} }
apiDocuments := make([]Document, len(rows)) apiDocuments := make([]Document, len(rows))
wordCounts := make([]WordCount, 0, len(rows))
for i, row := range rows { for i, row := range rows {
apiDocuments[i] = Document{ apiDocuments[i] = Document{
Id: row.ID, 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 UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
Deleted: false, // Default, should be overridden if available Deleted: false, // Default, should be overridden if available
} }
if row.Words != nil {
wordCounts = append(wordCounts, WordCount{
DocumentId: row.ID,
Count: *row.Words,
})
}
} }
response := DocumentsResponse{ response := DocumentsResponse{
@@ -99,8 +92,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
NextPage: nextPage, NextPage: nextPage,
PreviousPage: previousPage, PreviousPage: previousPage,
Search: request.Params.Search, Search: request.Params.Search,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
WordCounts: wordCounts,
} }
return GetDocuments200JSONResponse(response), nil return GetDocuments200JSONResponse(response), nil
} }
@@ -129,21 +120,6 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
doc := docs[0] 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{ apiDoc := Document{
Id: doc.ID, Id: doc.ID,
Title: *doc.Title, Title: *doc.Title,
@@ -165,7 +141,6 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
response := DocumentResponse{ response := DocumentResponse{
Document: apiDoc, Document: apiDoc,
Progress: progress,
} }
return GetDocument200JSONResponse(response), nil return GetDocument200JSONResponse(response), nil
} }
@@ -244,20 +219,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
doc := docs[0] 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{ apiDoc := Document{
Id: doc.ID, Id: doc.ID,
@@ -280,7 +241,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
response := DocumentResponse{ response := DocumentResponse{
Document: apiDoc, Document: apiDoc,
Progress: progress,
} }
return EditDocument200JSONResponse(response), nil return EditDocument200JSONResponse(response), nil
} }
@@ -601,20 +561,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
doc := docs[0] 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{ apiDoc := Document{
Id: doc.ID, Id: doc.ID,
@@ -637,7 +583,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
response := DocumentResponse{ response := DocumentResponse{
Document: apiDoc, Document: apiDoc,
Progress: progress,
} }
return UploadDocumentCover200JSONResponse(response), nil return UploadDocumentCover200JSONResponse(response), nil
} }

View File

@@ -108,7 +108,6 @@ func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(int64(1), resp.Page) suite.Equal(int64(1), resp.Page)
suite.Equal(int64(9), resp.Limit) suite.Equal(int64(9), resp.Limit)
suite.Equal("testuser", resp.User.Username)
} }
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() { func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {

View File

@@ -92,9 +92,13 @@ components:
type: string type: string
device_name: device_name:
type: string type: string
device_id:
type: string
percentage: percentage:
type: number type: number
format: double format: double
progress:
type: string
document_id: document_id:
type: string type: string
user_id: user_id:
@@ -103,6 +107,88 @@ components:
type: string type: string
format: date-time 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: Activity:
type: object type: object
properties: properties:
@@ -214,27 +300,17 @@ components:
format: int64 format: int64
search: search:
type: string type: string
user:
$ref: '#/components/schemas/UserData'
word_counts:
type: array
items:
$ref: '#/components/schemas/WordCount'
required: required:
- documents - documents
- total - total
- page - page
- limit - limit
- user
- word_counts
DocumentResponse: DocumentResponse:
type: object type: object
properties: properties:
document: document:
$ref: '#/components/schemas/Document' $ref: '#/components/schemas/Document'
progress:
$ref: '#/components/schemas/Progress'
required: required:
- document - document
@@ -594,6 +670,21 @@ components:
$ref: '#/components/schemas/LogEntry' $ref: '#/components/schemas/LogEntry'
filter: filter:
type: string 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: InfoResponse:
type: object type: object
@@ -611,8 +702,9 @@ components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
type: http type: apiKey
scheme: bearer in: cookie
name: token
paths: paths:
/documents: /documents:
@@ -987,6 +1079,44 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $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}: /progress/{id}:
get: get:
@@ -1077,6 +1207,44 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $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: /settings:
get: get:
@@ -1159,6 +1327,11 @@ paths:
responses: responses:
200: 200:
description: Successful login description: Successful login
headers:
Set-Cookie:
description: HttpOnly session cookie for authenticated requests.
schema:
type: string
content: content:
application/json: application/json:
schema: schema:
@@ -1197,6 +1370,11 @@ paths:
responses: responses:
201: 201:
description: Successful registration description: Successful registration
headers:
Set-Cookie:
description: HttpOnly session cookie for authenticated requests.
schema:
type: string
content: content:
application/json: application/json:
schema: schema:
@@ -1764,6 +1942,18 @@ paths:
in: query in: query
schema: schema:
type: string 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: security:
- BearerAuth: [] - BearerAuth: []
responses: responses:

View File

@@ -3,9 +3,10 @@ package v1
import ( import (
"context" "context"
"math" "math"
"time"
"reichard.io/antholume/database"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
) )
// GET /progress // GET /progress
@@ -87,30 +88,20 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
} }
filter := database.GetProgressParams{ row, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName, UserID: auth.UserName,
DocFilter: true,
DocumentID: request.Id, DocumentID: request.Id,
Offset: 0, })
Limit: 1,
}
progress, err := s.db.Queries.GetProgress(ctx, filter)
if err != nil { if err != nil {
log.Error("GetProgress DB Error:", err) log.Error("GetDocumentProgress DB Error:", err)
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil 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{ apiProgress := Progress{
Title: row.Title,
Author: row.Author,
DeviceName: &row.DeviceName, DeviceName: &row.DeviceName,
DeviceId: &row.DeviceID,
Percentage: &row.Percentage, Percentage: &row.Percentage,
Progress: &row.Progress,
DocumentId: &row.DocumentID, DocumentId: &row.DocumentID,
UserId: &row.UserID, UserId: &row.UserID,
CreatedAt: parseTimePtr(row.CreatedAt), CreatedAt: parseTimePtr(row.CreatedAt),
@@ -122,3 +113,51 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
return GetProgress200JSONResponse(response), nil 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
View File

@@ -1 +1,2 @@
node_modules node_modules
dist

76
frontend/AGENTS.md Normal file
View 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.

View 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`

View File

@@ -9,7 +9,8 @@
"ajv": "^8.18.0", "ajv": "^8.18.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "epubjs": "^0.3.93",
"nosleep.js": "^0.12.0",
"orval": "8.5.3", "orval": "8.5.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -18,6 +19,9 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@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": "^19.0.8",
"@types/react-dom": "^19.0.8", "@types/react-dom": "^19.0.8",
"@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/eslint-plugin": "^8.13.0",
@@ -30,17 +34,27 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"jsdom": "^29.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5", "vite": "^6.0.5",
"vitest": "^4.1.0",
}, },
}, },
}, },
"packages": { "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=="], "@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/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=="], "@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/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/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/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=="], "@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=="], "@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/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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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__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=="], "@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/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/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/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/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/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=="], "@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=="], "@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": ["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=="], "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=="], "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-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=="], "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=="], "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=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "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=="], "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=="], "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=="], "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=="], "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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "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=="], "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=="], "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=="], "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=="], "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-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-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=="], "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=="], "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=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
"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=="],
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], "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=="], "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=="], "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=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "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=="], "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=="], "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=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -735,12 +875,16 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "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-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=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "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=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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-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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "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=="], "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=="], "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.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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "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=="], "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-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=="], "@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=="], "@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/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=="], "@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=="], "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=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "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=="], "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=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -5,19 +5,23 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"generate:api": "orval", "generate:api": "orval",
"lint": "eslint src --max-warnings=0", "lint": "eslint src --max-warnings=0",
"lint:fix": "eslint src --fix", "lint:fix": "eslint src --fix",
"format": "prettier --check src", "format": "prettier --check src",
"format:fix": "prettier --write src" "format:fix": "prettier --write src",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.16", "@tanstack/react-query": "^5.62.16",
"ajv": "^8.18.0", "ajv": "^8.18.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"epubjs": "^0.3.93",
"nosleep.js": "^0.12.0",
"orval": "8.5.3", "orval": "8.5.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -26,6 +30,9 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@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": "^19.0.8",
"@types/react-dom": "^19.0.8", "@types/react-dom": "^19.0.8",
"@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/eslint-plugin": "^8.13.0",
@@ -38,10 +45,12 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"jsdom": "^29.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5" "vite": "^6.0.5",
"vitest": "^4.1.0"
} }
} }

View File

@@ -14,6 +14,7 @@ import AdminImportPage from './pages/AdminImportPage';
import AdminImportResultsPage from './pages/AdminImportResultsPage'; import AdminImportResultsPage from './pages/AdminImportResultsPage';
import AdminUsersPage from './pages/AdminUsersPage'; import AdminUsersPage from './pages/AdminUsersPage';
import AdminLogsPage from './pages/AdminLogsPage'; import AdminLogsPage from './pages/AdminLogsPage';
import ReaderPage from './pages/ReaderPage';
import { ProtectedRoute } from './auth/ProtectedRoute'; import { ProtectedRoute } from './auth/ProtectedRoute';
export function Routes() { export function Routes() {
@@ -118,6 +119,14 @@ export function Routes() {
} }
/> />
</Route> </Route>
<Route
path="/reader/:id"
element={
<ProtectedRoute>
<ReaderPage />
</ProtectedRoute>
}
/>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
</ReactRoutes> </ReactRoutes>

View File

@@ -8,12 +8,13 @@ import {
useGetMe, useGetMe,
useRegister, useRegister,
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import {
interface AuthState { type AuthState,
isAuthenticated: boolean; getAuthenticatedAuthState,
user: { username: string; is_admin: boolean } | null; getUnauthenticatedAuthState,
isCheckingAuth: boolean; resolveAuthStateFromMe,
} validateAuthMutationResponse,
} from './authHelpers';
interface AuthContextType extends AuthState { interface AuthContextType extends AuthState {
login: (_username: string, _password: string) => Promise<void>; login: (_username: string, _password: string) => Promise<void>;
@@ -23,12 +24,14 @@ interface AuthContextType extends AuthState {
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { const initialAuthState: AuthState = {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false, isAuthenticated: false,
user: null, user: null,
isCheckingAuth: true, isCheckingAuth: true,
}); };
export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
const loginMutation = useLogin(); const loginMutation = useLogin();
const registerMutation = useRegister(); const registerMutation = useRegister();
@@ -40,26 +43,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
setAuthState(prev => { setAuthState(prev =>
if (meLoading) { resolveAuthStateFromMe({
return { ...prev, isCheckingAuth: true }; meData,
} else if (meData?.data && meData.status === 200) { meError,
const userData = 'username' in meData.data ? meData.data : null; meLoading,
return { previousState: prev,
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 };
});
}, [meData, meError, meLoading]); }, [meData, meError, meLoading]);
const login = useCallback( const login = useCallback(
@@ -72,29 +63,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, },
}); });
if (response.status !== 200 || !('username' in response.data)) { const user = validateAuthMutationResponse(response, 200);
setAuthState({ if (!user) {
isAuthenticated: false, setAuthState(getUnauthenticatedAuthState());
user: null,
isCheckingAuth: false,
});
throw new Error('Login failed'); throw new Error('Login failed');
} }
setAuthState({ setAuthState(getAuthenticatedAuthState(user));
isAuthenticated: true,
user: response.data as { username: string; is_admin: boolean },
isCheckingAuth: false,
});
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/'); navigate('/');
} catch (_error) { } catch (_error) {
setAuthState({ setAuthState(getUnauthenticatedAuthState());
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
throw new Error('Login failed'); throw new Error('Login failed');
} }
}, },
@@ -111,29 +91,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, },
}); });
if (response.status !== 201 || !('username' in response.data)) { const user = validateAuthMutationResponse(response, 201);
setAuthState({ if (!user) {
isAuthenticated: false, setAuthState(getUnauthenticatedAuthState());
user: null,
isCheckingAuth: false,
});
throw new Error('Registration failed'); throw new Error('Registration failed');
} }
setAuthState({ setAuthState(getAuthenticatedAuthState(user));
isAuthenticated: true,
user: response.data as { username: string; is_admin: boolean },
isCheckingAuth: false,
});
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/'); navigate('/');
} catch (_error) { } catch (_error) {
setAuthState({ setAuthState(getUnauthenticatedAuthState());
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
throw new Error('Registration failed'); throw new Error('Registration failed');
} }
}, },
@@ -143,11 +112,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(() => { const logout = useCallback(() => {
logoutMutation.mutate(undefined, { logoutMutation.mutate(undefined, {
onSuccess: async () => { onSuccess: async () => {
setAuthState({ setAuthState(getUnauthenticatedAuthState());
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() }); await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
navigate('/login'); navigate('/login');
}, },

View 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();
});
});

View File

@@ -9,13 +9,11 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isCheckingAuth } = useAuth(); const { isAuthenticated, isCheckingAuth } = useAuth();
const location = useLocation(); const location = useLocation();
// Show loading while checking authentication status
if (isCheckingAuth) { if (isCheckingAuth) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-content-muted">Loading...</div>;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to login with the current location saved
return <Navigate to="/login" state={{ from: location }} replace />; return <Navigate to="/login" state={{ from: location }} replace />;
} }

View 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();
});
});

View 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);
}

View 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();
});
});

View File

@@ -1,35 +1,3 @@
import axios from 'axios'; export function setupAuthInterceptors() {
return () => {};
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}`;
}
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;

View File

@@ -11,13 +11,13 @@ type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { h
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass = 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') { 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>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(

View File

@@ -9,7 +9,7 @@ interface FieldProps {
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) { export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
return ( return (
<div className="relative rounded"> <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} {children}
</div> </div>
); );

View File

@@ -26,7 +26,6 @@ const adminSubItems: NavItem[] = [
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' }, { 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 { function hasPrefix(path: string, prefix: string): boolean {
return path.startsWith(prefix); return path.startsWith(prefix);
} }
@@ -37,10 +36,9 @@ export default function HamburgerMenu() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const isAdmin = user?.is_admin ?? false; const isAdmin = user?.is_admin ?? false;
// Fetch server info for version
const { data: infoData } = useGetInfo({ const { data: infoData } = useGetInfo({
query: { query: {
staleTime: Infinity, // Info doesn't change frequently staleTime: Infinity,
}, },
}); });
const version = const version =
@@ -50,7 +48,6 @@ export default function HamburgerMenu() {
return ( return (
<div className="relative z-40 ml-6 flex flex-col"> <div className="relative z-40 ml-6 flex flex-col">
{/* Checkbox input for state management */}
<input <input
type="checkbox" type="checkbox"
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden" 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)} onChange={e => setIsOpen(e.target.checked)}
/> />
{/* Hamburger icon lines with CSS animations - hidden on desktop */}
<span <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={{ style={{
transformOrigin: '5px 0px', transformOrigin: '5px 0px',
transition: transition:
@@ -70,7 +66,7 @@ export default function HamburgerMenu() {
}} }}
/> />
<span <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={{ style={{
transformOrigin: '0% 100%', transformOrigin: '0% 100%',
transition: transition:
@@ -80,7 +76,7 @@ export default function HamburgerMenu() {
}} }}
/> />
<span <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={{ style={{
transformOrigin: '0% 0%', transformOrigin: '0% 0%',
transition: transition:
@@ -89,21 +85,17 @@ export default function HamburgerMenu() {
}} }}
/> />
{/* Navigation menu with slide animation */}
<div <div
id="menu" 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={{ style={{
top: 0, top: 0,
paddingTop: 'env(safe-area-inset-top)', paddingTop: 'env(safe-area-inset-top)',
transformOrigin: '0% 0%', transformOrigin: '0% 0%',
// On desktop (lg), always show the menu via CSS class
// On mobile, control via state
transform: isOpen ? 'none' : 'translate(-100%, 0)', transform: isOpen ? 'none' : 'translate(-100%, 0)',
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)', transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
}} }}
> >
{/* Desktop override - always visible */}
<style>{` <style>{`
@media (min-width: 1024px) { @media (min-width: 1024px) {
#menu { #menu {
@@ -112,9 +104,7 @@ export default function HamburgerMenu() {
} }
`}</style> `}</style>
<div className="flex h-16 justify-end lg:justify-around"> <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"> <p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">AnthoLume</p>
AnthoLume
</p>
</div> </div>
<nav> <nav>
{navItems.map(item => ( {navItems.map(item => (
@@ -124,8 +114,8 @@ export default function HamburgerMenu() {
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${ 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 location.pathname === item.path
? 'border-purple-500 dark:text-white' ? 'border-primary-500 text-content'
: 'border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100' : 'border-transparent text-content-subtle hover:text-content'
}`} }`}
> >
<item.icon size={20} /> <item.icon size={20} />
@@ -133,23 +123,21 @@ export default function HamburgerMenu() {
</Link> </Link>
))} ))}
{/* Admin section - only visible for admins */}
{isAdmin && ( {isAdmin && (
<div <div
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${ className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
hasPrefix(location.pathname, '/admin') hasPrefix(location.pathname, '/admin')
? 'border-purple-500 dark:text-white' ? 'border-primary-500 text-content'
: 'border-transparent text-gray-400' : 'border-transparent text-content-subtle'
}`} }`}
> >
{/* Admin header - always shown */}
<Link <Link
to="/admin" to="/admin"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className={`flex w-full justify-start ${ className={`flex w-full justify-start ${
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/') location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
? 'dark:text-white' ? 'text-content'
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100' : 'text-content-subtle hover:text-content'
}`} }`}
> >
<SettingsIcon size={20} /> <SettingsIcon size={20} />
@@ -165,8 +153,8 @@ export default function HamburgerMenu() {
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className={`flex w-full justify-start ${ className={`flex w-full justify-start ${
location.pathname === item.path location.pathname === item.path
? 'dark:text-white' ? 'text-content'
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100' : 'text-content-subtle hover:text-content'
}`} }`}
style={{ paddingLeft: '1.75em' }} style={{ paddingLeft: '1.75em' }}
> >
@@ -179,7 +167,7 @@ export default function HamburgerMenu() {
)} )}
</nav> </nav>
<a <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" target="_blank"
href="https://gitea.va.reichard.io/evan/AnthoLume" href="https://gitea.va.reichard.io/evan/AnthoLume"
rel="noreferrer" rel="noreferrer"

View File

@@ -3,11 +3,16 @@ import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useGetMe } from '../generated/anthoLumeAPIV1'; import { useGetMe } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { UserIcon, DropdownIcon } from '../icons'; import { UserIcon, DropdownIcon } from '../icons';
import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings';
import HamburgerMenu from './HamburgerMenu'; import HamburgerMenu from './HamburgerMenu';
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth(); const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
const { themeMode, setThemeMode } = useTheme();
const { data } = useGetMe(isAuthenticated ? {} : undefined); const { data } = useGetMe(isAuthenticated ? {} : undefined);
const fetchedUser = const fetchedUser =
data?.status === 200 && data.data && 'username' in data.data ? data.data : null; data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
@@ -20,7 +25,6 @@ export default function Layout() {
setIsUserDropdownOpen(false); setIsUserDropdownOpen(false);
}; };
// Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@@ -34,7 +38,6 @@ export default function Layout() {
}; };
}, []); }, []);
// Get current page title
const navItems = [ const navItems = [
{ path: '/admin/import-results', title: 'Admin - Import' }, { path: '/admin/import-results', title: 'Admin - Import' },
{ path: '/admin/import', title: 'Admin - Import' }, { path: '/admin/import', title: 'Admin - Import' },
@@ -57,43 +60,62 @@ export default function Layout() {
document.title = `AnthoLume - ${currentPageTitle}`; document.title = `AnthoLume - ${currentPageTitle}`;
}, [currentPageTitle]); }, [currentPageTitle]);
// Show loading while checking authentication status
if (isCheckingAuth) { 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) { if (!isAuthenticated) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
return ( return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-800"> <div className="min-h-screen bg-canvas">
{/* Header */}
<div className="flex h-16 w-full items-center justify-between"> <div className="flex h-16 w-full items-center justify-between">
{/* Mobile Navigation Button with CSS animations */}
<HamburgerMenu /> <HamburgerMenu />
{/* Header Title */} <h1 className="whitespace-nowrap px-6 text-xl font-bold text-content lg:ml-44">
<h1 className="whitespace-nowrap px-6 text-xl font-bold lg:ml-44 dark:text-white">
{currentPageTitle} {currentPageTitle}
</h1> </h1>
{/* User Dropdown */}
<div <div
className="relative flex w-full items-center justify-end space-x-4 p-4" className="relative flex w-full items-center justify-end space-x-4 p-4"
ref={dropdownRef} ref={dropdownRef}
> >
<button <button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)} onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="relative block text-gray-800 dark:text-gray-200" className="relative block text-content"
> >
<UserIcon size={20} /> <UserIcon size={20} />
</button> </button>
{isUserDropdownOpen && ( {isUserDropdownOpen && (
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200"> <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 <div
className="py-1" className="py-1"
role="menu" role="menu"
@@ -103,7 +125,7 @@ export default function Layout() {
<Link <Link
to="/settings" to="/settings"
onClick={() => setIsUserDropdownOpen(false)} 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" role="menuitem"
> >
<span className="flex flex-col"> <span className="flex flex-col">
@@ -112,7 +134,7 @@ export default function Layout() {
</Link> </Link>
<button <button
onClick={handleLogout} 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" role="menuitem"
> >
<span className="flex flex-col"> <span className="flex flex-col">
@@ -126,11 +148,11 @@ export default function Layout() {
<button <button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)} 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>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
<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)' }} style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
> >
<DropdownIcon size={20} /> <DropdownIcon size={20} />
@@ -139,7 +161,6 @@ export default function Layout() {
</div> </div>
</div> </div>
{/* Main Content */}
<main <main
className="relative overflow-hidden" className="relative overflow-hidden"
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }} style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}

View 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>
);
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { getSVGGraphData } from './ReadingHistoryGraph'; import { getSVGGraphData } from './ReadingHistoryGraph';
// Test data matching Go test exactly // Intentionally exact fixture data for algorithm parity coverage
const testInput = [ const testInput = [
{ date: '2024-01-01', minutes_read: 10 }, { date: '2024-01-01', minutes_read: 10 },
{ date: '2024-01-02', minutes_read: 90 }, { date: '2024-01-02', minutes_read: 90 },
@@ -22,7 +23,7 @@ describe('ReadingHistoryGraph', () => {
it('should match exactly', () => { it('should match exactly', () => {
const result = getSVGGraphData(testInput, svgWidth, svgHeight); const result = getSVGGraphData(testInput, svgWidth, svgHeight);
// Expected values from Go test // Expected exact algorithm output
const expectedBezierPath = 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'; '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'; const expectedBezierFill = 'L 500,98 L 50,98 Z';
@@ -36,13 +37,13 @@ describe('ReadingHistoryGraph', () => {
expect(svgHeight).toBe(expectedHeight); expect(svgHeight).toBe(expectedHeight);
expect(result.Offset).toBe(expectedOffset); expect(result.Offset).toBe(expectedOffset);
// Verify line points are integers like Go // Verify line points are integer pixel values
result.LinePoints.forEach((p, _i) => { result.LinePoints.forEach((p, _i) => {
expect(Number.isInteger(p.x)).toBe(true); expect(Number.isInteger(p.x)).toBe(true);
expect(Number.isInteger(p.y)).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 0: itemSize=5, itemY=95, lineX=50
// idx 1: itemSize=45, itemY=55, lineX=100 // idx 1: itemSize=45, itemY=55, lineX=100
// idx 2: itemSize=25, itemY=75, lineX=150 // idx 2: itemSize=25, itemY=75, lineX=150

View File

@@ -9,9 +9,6 @@ export interface SVGPoint {
y: number; y: number;
} }
/**
* Generates bezier control points for smooth curves
*/
function getSVGBezierOpposedLine( function getSVGBezierOpposedLine(
pointA: SVGPoint, pointA: SVGPoint,
pointB: SVGPoint pointB: SVGPoint
@@ -19,7 +16,6 @@ function getSVGBezierOpposedLine(
const lengthX = pointB.x - pointA.x; const lengthX = pointB.x - pointA.x;
const lengthY = pointB.y - pointA.y; const lengthY = pointB.y - pointA.y;
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
return { return {
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)), Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
Angle: Math.trunc(Math.atan2(lengthY, lengthX)), Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
@@ -32,7 +28,6 @@ function getBezierControlPoint(
nextPoint: SVGPoint | null, nextPoint: SVGPoint | null,
isReverse: boolean isReverse: boolean
): SVGPoint { ): SVGPoint {
// First / Last Point
let pPrev = prevPoint; let pPrev = prevPoint;
let pNext = nextPoint; let pNext = nextPoint;
if (!pPrev) { if (!pPrev) {
@@ -42,57 +37,49 @@ function getBezierControlPoint(
pNext = currentPoint; pNext = currentPoint;
} }
// Modifiers const smoothingRatio = 0.2;
const smoothingRatio: number = 0.2; const directionModifier = isReverse ? Math.PI : 0;
const directionModifier: number = isReverse ? Math.PI : 0;
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext); const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
const lineAngle: number = opposingLine.Angle + directionModifier; const lineAngle = opposingLine.Angle + directionModifier;
const lineLength: number = opposingLine.Length * smoothingRatio; 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 { return {
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)), x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(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 { function getSVGBezierPath(points: SVGPoint[]): string {
if (points.length === 0) { if (points.length === 0) {
return ''; return '';
} }
let bezierSVGPath: string = ''; let bezierSVGPath = '';
for (let index = 0; index < points.length; index++) { for (let index = 0; index < points.length; index++) {
const point = points[index]; const point = points[index];
if (!point) {
continue;
}
if (index === 0) { if (index === 0) {
bezierSVGPath += `M ${point.x},${point.y}`; bezierSVGPath += `M ${point.x},${point.y}`;
} else { continue;
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}`;
} }
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; return bezierSVGPath;
@@ -105,49 +92,39 @@ export interface SVGGraphData {
Offset: number; Offset: number;
} }
/**
* Get SVG Graph Data
*/
export function getSVGGraphData( export function getSVGGraphData(
inputData: GraphDataPoint[], inputData: GraphDataPoint[],
svgWidth: number, svgWidth: number,
svgHeight: number svgHeight: number
): SVGGraphData { ): SVGGraphData {
// Derive Height let maxHeight = 0;
let maxHeight: number = 0;
for (const item of inputData) { for (const item of inputData) {
if (item.minutes_read > maxHeight) { if (item.minutes_read > maxHeight) {
maxHeight = item.minutes_read; maxHeight = item.minutes_read;
} }
} }
// Vertical Graph Real Estate const sizePercentage = 0.5;
const sizePercentage: number = 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[] = []; const linePoints: SVGPoint[] = [];
// Bezier Fill Coordinates (Max X, Min X, Max Y) let maxBX = 0;
let maxBX: number = 0; let maxBY = 0;
let maxBY: number = 0; let minBX = 0;
let minBX: number = 0;
for (let idx = 0; idx < inputData.length; idx++) { for (let idx = 0; idx < inputData.length; idx++) {
// Go uses int conversion const item = inputData[idx];
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio); if (!item) {
continue;
}
const itemSize = Math.floor(item.minutes_read * sizeRatio);
const itemY = svgHeight - itemSize; const itemY = svgHeight - itemSize;
const lineX = (idx + 1) * blockOffset; const lineX = (idx + 1) * blockOffset;
linePoints.push({ linePoints.push({ x: lineX, y: itemY });
x: lineX,
y: itemY,
});
if (lineX > maxBX) { if (lineX > maxBX) {
maxBX = lineX; maxBX = lineX;
@@ -162,7 +139,6 @@ export function getSVGGraphData(
} }
} }
// Return Data
return { return {
LinePoints: linePoints, LinePoints: linePoints,
BezierPath: getSVGBezierPath(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 { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
// Use UTC methods to avoid timezone offset issues
const year = date.getUTCFullYear(); const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0'); const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`; 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) { export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
const svgWidth = 800; const svgWidth = 800;
const svgHeight = 70; const svgHeight = 70;
if (!data || data.length < 2) { if (!data || data.length < 2) {
return ( return (
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600"> <div className="relative flex h-24 items-center justify-center bg-surface-muted">
<p className="text-gray-400 dark:text-gray-300">No data available</p> <p className="text-content-subtle">No data available</p>
</div> </div>
); );
} }
const { const { BezierPath, BezierFill } = getSVGGraphData(data, svgWidth, svgHeight);
BezierPath,
BezierFill,
LinePoints: _linePoints,
} = getSVGGraphData(data, svgWidth, svgHeight);
return ( return (
<div className="relative"> <div className="relative">
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em"> <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="rgb(var(--secondary-600))" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
<path fill="none" stroke="#316BBE" d={BezierPath} /> <path fill="none" stroke="rgb(var(--secondary-600))" d={BezierPath} />
</svg> </svg>
<div <div
className="absolute top-0 flex size-full" className="absolute top-0 flex size-full"
@@ -227,7 +186,6 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
{data.map((point, i) => ( {data.map((point, i) => (
<div <div
key={i} key={i}
onClick
className="w-full opacity-0 hover:opacity-100" className="w-full opacity-0 hover:opacity-100"
style={{ style={{
background: background:
@@ -235,11 +193,10 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
}} }}
> >
<div <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={{ style={{
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
left: '50%', left: '50%',
backgroundColor: 'rgba(128, 128, 128, 0.2)',
}} }}
> >
<span>{formatDate(point.date)}</span> <span>{formatDate(point.date)}</span>

View File

@@ -15,11 +15,11 @@ export function Skeleton({
height, height,
animation = 'pulse', animation = 'pulse',
}: SkeletonProps) { }: SkeletonProps) {
const baseClasses = 'bg-gray-200 dark:bg-gray-600'; const baseClasses = 'bg-surface-strong';
const variantClasses = { const variantClasses = {
default: 'rounded', default: 'rounded',
text: 'rounded-md h-4', text: 'h-4 rounded-md',
circular: 'rounded-full', circular: 'rounded-full',
rectangular: 'rounded-none', rectangular: 'rounded-none',
}; };
@@ -97,12 +97,7 @@ export function SkeletonCard({
textLines = 3, textLines = 3,
}: SkeletonCardProps) { }: SkeletonCardProps) {
return ( return (
<div <div className={cn('rounded-lg border border-border bg-surface p-4', className)}>
className={cn(
'bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600',
className
)}
>
{showAvatar && ( {showAvatar && (
<div className="mb-4 flex items-start gap-4"> <div className="mb-4 flex items-start gap-4">
<SkeletonAvatar /> <SkeletonAvatar />
@@ -132,11 +127,11 @@ export function SkeletonTable({
showHeader = true, showHeader = true,
}: SkeletonTableProps) { }: SkeletonTableProps) {
return ( 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"> <table className="min-w-full">
{showHeader && ( {showHeader && (
<thead> <thead>
<tr className="border-b dark:border-gray-600"> <tr className="border-b border-border">
{Array.from({ length: columns }).map((_, i) => ( {Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3"> <th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" /> <Skeleton variant="text" className="h-5 w-3/4" />
@@ -147,7 +142,7 @@ export function SkeletonTable({
)} )}
<tbody> <tbody>
{Array.from({ length: rows }).map((_, rowIndex) => ( {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) => ( {Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3"> <td key={colIndex} className="p-3">
<Skeleton <Skeleton
@@ -187,11 +182,11 @@ interface PageLoaderProps {
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) { export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
return ( 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="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> </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> </div>
); );
} }
@@ -203,19 +198,18 @@ interface InlineLoaderProps {
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) { export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
const sizeMap = { const sizeMap = {
sm: 'w-4 h-4 border-2', sm: 'h-4 w-4 border-2',
md: 'w-6 h-6 border-3', md: 'h-6 w-6 border-[3px]',
lg: 'w-8 h-8 border-4', lg: 'h-8 w-8 border-4',
}; };
return ( return (
<div className={cn('flex items-center justify-center', className)}> <div className={cn('flex items-center justify-center', className)}>
<div <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> </div>
); );
} }
// Re-export SkeletonTable for backward compatibility
export { SkeletonTable as SkeletonTableExport }; export { SkeletonTable as SkeletonTableExport };

View 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();
});
});

View File

@@ -2,14 +2,14 @@ import React from 'react';
import { Skeleton } from './Skeleton'; import { Skeleton } from './Skeleton';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
export interface Column<T extends Record<string, unknown>> { export interface Column<T extends object> {
key: keyof T; key: keyof T;
header: string; header: string;
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode; render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
className?: string; className?: string;
} }
export interface TableProps<T extends Record<string, unknown>> { export interface TableProps<T extends object> {
columns: Column<T>[]; columns: Column<T>[];
data: T[]; data: T[];
loading?: boolean; loading?: boolean;
@@ -17,7 +17,6 @@ export interface TableProps<T extends Record<string, unknown>> {
rowKey?: keyof T | ((row: T) => string); rowKey?: keyof T | ((row: T) => string);
} }
// Skeleton table component for loading state
function SkeletonTable({ function SkeletonTable({
rows = 5, rows = 5,
columns = 4, columns = 4,
@@ -28,10 +27,10 @@ function SkeletonTable({
className?: string; className?: string;
}) { }) {
return ( 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"> <table className="min-w-full">
<thead> <thead>
<tr className="border-b dark:border-gray-600"> <tr className="border-b border-border">
{Array.from({ length: columns }).map((_, i) => ( {Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3"> <th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" /> <Skeleton variant="text" className="h-5 w-3/4" />
@@ -41,7 +40,7 @@ function SkeletonTable({
</thead> </thead>
<tbody> <tbody>
{Array.from({ length: rows }).map((_, rowIndex) => ( {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) => ( {Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3"> <td key={colIndex} className="p-3">
<Skeleton <Skeleton
@@ -58,19 +57,19 @@ function SkeletonTable({
); );
} }
export function Table<T extends Record<string, unknown>>({ export function Table<T extends object>({
columns, columns,
data, data,
loading = false, loading = false,
emptyMessage = 'No Results', emptyMessage = 'No Results',
rowKey, rowKey,
}: TableProps<T>) { }: TableProps<T>) {
const getRowKey = (_row: T, index: number): string => { const getRowKey = (row: T, index: number): string => {
if (typeof rowKey === 'function') { if (typeof rowKey === 'function') {
return rowKey(_row); return rowKey(row);
} }
if (rowKey) { if (rowKey) {
return String(_row[rowKey] ?? index); return String(row[rowKey] ?? index);
} }
return `row-${index}`; return `row-${index}`;
}; };
@@ -82,13 +81,13 @@ export function Table<T extends Record<string, unknown>>({
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <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> <thead>
<tr className="border-b dark:border-gray-600"> <tr className="border-b border-border">
{columns.map(column => ( {columns.map(column => (
<th <th
key={String(column.key)} 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} {column.header}
</th> </th>
@@ -98,22 +97,21 @@ export function Table<T extends Record<string, unknown>>({
<tbody> <tbody>
{data.length === 0 ? ( {data.length === 0 ? (
<tr> <tr>
<td <td colSpan={columns.length} className="p-3 text-center text-content-muted">
colSpan={columns.length}
className="p-3 text-center text-gray-700 dark:text-gray-300"
>
{emptyMessage} {emptyMessage}
</td> </td>
</tr> </tr>
) : ( ) : (
data.map((row, index) => ( 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 => ( {columns.map(column => (
<td <td
key={`${getRowKey(row, index)}-${String(column.key)}`} 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> </td>
))} ))}
</tr> </tr>

View File

@@ -13,24 +13,24 @@ export interface ToastProps {
const getToastStyles = (_type: ToastType) => { const getToastStyles = (_type: ToastType) => {
const baseStyles = 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 = { const typeStyles = {
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400', info: 'border-secondary-500 bg-secondary-100',
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-500 dark:border-yellow-400', warning: 'border-yellow-500 bg-yellow-100',
error: 'bg-red-50 dark:bg-red-900/30 border-red-500 dark:border-red-400', error: 'border-red-500 bg-red-100',
}; };
const iconStyles = { const iconStyles = {
info: 'text-blue-600 dark:text-blue-400', info: 'text-secondary-700',
warning: 'text-yellow-600 dark:text-yellow-400', warning: 'text-yellow-700',
error: 'text-red-600 dark:text-red-400', error: 'text-red-700',
}; };
const textStyles = { const textStyles = {
info: 'text-blue-800 dark:text-blue-200', info: 'text-secondary-900',
warning: 'text-yellow-800 dark:text-yellow-200', warning: 'text-yellow-900',
error: 'text-red-800 dark:text-red-200', error: 'text-red-900',
}; };
return { baseStyles, typeStyles, iconStyles, textStyles }; return { baseStyles, typeStyles, iconStyles, textStyles };

View File

@@ -17,6 +17,7 @@ export {
PageLoader, PageLoader,
InlineLoader, InlineLoader,
} from './Skeleton'; } from './Skeleton';
export { LoadingState } from './LoadingState';
// Field components // Field components
export { Field, FieldLabel, FieldValue, FieldActions } from './Field'; export { Field, FieldLabel, FieldValue, FieldActions } from './Field';

View File

@@ -26,6 +26,8 @@ import type {
import type { import type {
ActivityResponse, ActivityResponse,
CreateActivityRequest,
CreateActivityResponse,
CreateDocumentBody, CreateDocumentBody,
DirectoryListResponse, DirectoryListResponse,
DocumentResponse, DocumentResponse,
@@ -55,6 +57,8 @@ import type {
SearchResponse, SearchResponse,
SettingsResponse, SettingsResponse,
StreaksResponse, StreaksResponse,
UpdateProgressRequest,
UpdateProgressResponse,
UpdateSettingsRequest, UpdateSettingsRequest,
UpdateUserBody, UpdateUserBody,
UploadDocumentCoverBody, 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 * @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 * @summary Get user settings
*/ */

View 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;
}

View 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[];
}

View 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;
}

View File

@@ -6,9 +6,7 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
import type { Document } from './document'; import type { Document } from './document';
import type { Progress } from './progress';
export interface DocumentResponse { export interface DocumentResponse {
document: Document; document: Document;
progress?: Progress;
} }

View File

@@ -6,8 +6,6 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
import type { Document } from './document'; import type { Document } from './document';
import type { UserData } from './userData';
import type { WordCount } from './wordCount';
export interface DocumentsResponse { export interface DocumentsResponse {
documents: Document[]; documents: Document[];
@@ -17,6 +15,4 @@ export interface DocumentsResponse {
next_page?: number; next_page?: number;
previous_page?: number; previous_page?: number;
search?: string; search?: string;
user: UserData;
word_counts: WordCount[];
} }

View File

@@ -8,4 +8,12 @@
export type GetLogsParams = { export type GetLogsParams = {
filter?: string; filter?: string;
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
*/
limit?: number;
}; };

View File

@@ -10,6 +10,9 @@ export * from './activity';
export * from './activityResponse'; export * from './activityResponse';
export * from './backupType'; export * from './backupType';
export * from './configResponse'; export * from './configResponse';
export * from './createActivityItem';
export * from './createActivityRequest';
export * from './createActivityResponse';
export * from './createDocumentBody'; export * from './createDocumentBody';
export * from './databaseInfo'; export * from './databaseInfo';
export * from './device'; export * from './device';
@@ -57,6 +60,8 @@ export * from './setting';
export * from './settingsResponse'; export * from './settingsResponse';
export * from './streaksResponse'; export * from './streaksResponse';
export * from './updateDocumentBody'; export * from './updateDocumentBody';
export * from './updateProgressRequest';
export * from './updateProgressResponse';
export * from './updateSettingsRequest'; export * from './updateSettingsRequest';
export * from './updateUserBody'; export * from './updateUserBody';
export * from './uploadDocumentCoverBody'; export * from './uploadDocumentCoverBody';

View File

@@ -10,4 +10,9 @@ import type { LogEntry } from './logEntry';
export interface LogsResponse { export interface LogsResponse {
logs?: LogEntry[]; logs?: LogEntry[];
filter?: string; filter?: string;
page?: number;
limit?: number;
next_page?: number;
previous_page?: number;
total?: number;
} }

View File

@@ -10,7 +10,9 @@ export interface Progress {
title?: string; title?: string;
author?: string; author?: string;
device_name?: string; device_name?: string;
device_id?: string;
percentage?: number; percentage?: number;
progress?: string;
document_id?: string; document_id?: string;
user_id?: string; user_id?: string;
created_at?: string; created_at?: string;

View 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;
}

View 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;
}

View 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');
});
});

View 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]
);
}

View File

@@ -4,6 +4,7 @@ interface BaseIconProps {
size?: number; size?: number;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
hoverable?: boolean;
viewBox?: string; viewBox?: string;
children: ReactNode; children: ReactNode;
} }
@@ -12,12 +13,15 @@ export function BaseIcon({
size = 24, size = 24,
className = '', className = '',
disabled = false, disabled = false,
hoverable = true,
viewBox = '0 0 24 24', viewBox = '0 0 24 24',
children, children,
}: BaseIconProps) { }: BaseIconProps) {
const disabledClasses = disabled const disabledClasses = disabled
? 'text-gray-200 dark:text-gray-600' ? 'text-content-subtle'
: 'hover:text-gray-800 dark:hover:text-gray-100'; : hoverable
? 'hover:text-content'
: '';
return ( return (
<svg <svg

View File

@@ -1,9 +1,14 @@
export function GitIcon() { interface GitIconProps {
size?: number;
className?: string;
}
export function GitIcon({ size = 20, className = '' }: GitIconProps) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className="text-black dark:text-white" className={`${className} text-content`.trim()}
height="20" height={size}
viewBox="0 0 219 92" viewBox="0 0 219 92"
fill="currentColor" fill="currentColor"
> >
@@ -22,19 +27,19 @@ export function GitIcon() {
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }} 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" 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 <path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }} 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" 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>
<g clip-path="url(#b)"> <g clipPath="url(#b)">
<path <path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }} 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" 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>
<g clip-path="url(#c)"> <g clipPath="url(#c)">
<path <path
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }} 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" 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"

View File

@@ -4,11 +4,12 @@ interface Search2IconProps {
size?: number; size?: number;
className?: string; className?: string;
disabled?: boolean; 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 ( return (
<BaseIcon size={size} className={className} disabled={disabled}> <BaseIcon size={size} className={className} disabled={disabled} hoverable={hoverable}>
<rect width="24" height="24" fill="none" /> <rect width="24" height="24" fill="none" />
<path <path
fillRule="evenodd" fillRule="evenodd"

View File

@@ -2,16 +2,208 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* PWA Styling */ :root {
html, --white: 255 255 255;
body { --black: 0 0 0;
overscroll-behavior-y: none;
margin: 0px; --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;
} }
html { .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: 0;
}
html {
height: calc(100% + env(safe-area-inset-bottom)); 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); 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 { main {
@@ -46,7 +238,7 @@ main {
/* Mobile Navigation */ /* Mobile Navigation */
#mobile-nav-button span { #mobile-nav-button span {
transform-origin: 5px 0px; transform-origin: 5px 0;
transition: transition:
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
background 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 { #mobile-nav-button span:first-child {
transform-origin: 0% 0%; transform-origin: 0 0;
} }
#mobile-nav-button span:nth-last-child(2) { #mobile-nav-button span:nth-last-child(2) {
transform-origin: 0% 100%; transform-origin: 0 100%;
} }
#mobile-nav-button:checked ~ span { #mobile-nav-button:checked ~ span {
@@ -88,7 +280,7 @@ main {
#menu { #menu {
top: 0; top: 0;
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
transform-origin: 0% 0%; transform-origin: 0 0;
transform: translate(-100%, 0); transform: translate(-100%, 0);
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1); transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
} }
@@ -112,9 +304,9 @@ main {
.animate-wave { .animate-wave {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgb(229, 231, 235) 0%, rgb(var(--neutral-200)) 0%,
rgb(243, 244, 246) 50%, rgb(var(--neutral-100)) 50%,
rgb(229, 231, 235) 100% rgb(var(--neutral-200)) 100%
); );
background-size: 200% 100%; background-size: 200% 100%;
animation: wave 1.5s ease-in-out infinite; animation: wave 1.5s ease-in-out infinite;
@@ -123,9 +315,9 @@ main {
.dark .animate-wave { .dark .animate-wave {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgb(75, 85, 99) 0%, rgb(var(--neutral-600)) 0%,
rgb(107, 114, 128) 50%, rgb(var(--neutral-500)) 50%,
rgb(75, 85, 99) 100% rgb(var(--neutral-600)) 100%
); );
background-size: 200% 100%; background-size: 200% 100%;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,16 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from './components/ToastContext'; import { ToastProvider } from './components/ToastContext';
import './auth/authInterceptor'; import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
initializeThemeMode();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5,
retry: 1, retry: 1,
}, },
mutations: { mutations: {
@@ -23,9 +25,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<ThemeProvider>
<ToastProvider> <ToastProvider>
<App /> <App />
</ToastProvider> </ToastProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode> </React.StrictMode>

View File

@@ -1,22 +1,19 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetActivity } from '../generated/anthoLumeAPIV1'; import { useGetActivity } from '../generated/anthoLumeAPIV1';
import type { Activity } from '../generated/model'; import type { Activity } from '../generated/model';
import { Table } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
export default function ActivityPage() { export default function ActivityPage() {
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 }); const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
const activities = data?.status === 200 ? data.data.activities : []; const activities = data?.status === 200 ? data.data.activities : [];
const columns = [ const columns: Column<Activity>[] = [
{ {
key: 'document_id' as const, key: 'document_id' as const,
header: 'Document', header: 'Document',
render: (_value: Activity['document_id'], row: Activity) => ( render: (_value, row) => (
<Link <Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
to={`/documents/${row.document_id}`}
className="text-blue-600 hover:underline dark:text-blue-400"
>
{row.author || 'Unknown'} - {row.title || 'Unknown'} {row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link> </Link>
), ),
@@ -24,19 +21,17 @@ export default function ActivityPage() {
{ {
key: 'start_time' as const, key: 'start_time' as const,
header: 'Time', header: 'Time',
render: (value: Activity['start_time']) => value || 'N/A', render: value => String(value || 'N/A'),
}, },
{ {
key: 'duration' as const, key: 'duration' as const,
header: 'Duration', header: 'Duration',
render: (value: Activity['duration']) => { render: value => formatDuration(typeof value === 'number' ? value : 0),
return formatDuration(value || 0);
},
}, },
{ {
key: 'end_percentage' as const, key: 'end_percentage' as const,
header: 'Percent', header: 'Percent',
render: (value: Activity['end_percentage']) => (value != null ? `${value}%` : '0%'), render: value => (typeof value === 'number' ? `${value}%` : '0%'),
}, },
]; ];

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1'; import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import type { DirectoryItem, DirectoryListResponse } from '../generated/model';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons'; import { FolderOpenIcon } from '../icons';
@@ -17,8 +18,10 @@ export default function AdminImportPage() {
const postImport = usePostImport(); const postImport = usePostImport();
const directories = directoryData?.data?.items || []; const directoryResponse =
const currentPathDisplay = directoryData?.data?.current_path ?? currentPath ?? '/data'; directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null;
const directories = directoryResponse?.items ?? [];
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
const handleSelectDirectory = (directory: string) => { const handleSelectDirectory = (directory: string) => {
setSelectedDirectory(`${currentPath}/${directory}`); setSelectedDirectory(`${currentPath}/${directory}`);
@@ -45,7 +48,6 @@ export default function AdminImportPage() {
{ {
onSuccess: _response => { onSuccess: _response => {
showInfo('Import completed successfully'); showInfo('Import completed successfully');
// Redirect to import results page after a short delay
setTimeout(() => { setTimeout(() => {
window.location.href = '/admin/import-results'; window.location.href = '/admin/import-results';
}, 1500); }, 1500);
@@ -62,22 +64,22 @@ export default function AdminImportPage() {
}; };
if (isLoading && !currentPath) { if (isLoading && !currentPath) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-content-muted">Loading...</div>;
} }
if (selectedDirectory) { if (selectedDirectory) {
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <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"> <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-gray-500">Selected Import Directory</p> <p className="text-lg font-semibold text-content">Selected Import Directory</p>
<form className="flex flex-col gap-4" onSubmit={handleImport}> <form className="flex flex-col gap-4" onSubmit={handleImport}>
<div className="flex w-full justify-between gap-4"> <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} /> <FolderOpenIcon size={20} />
<p className="break-all text-lg font-medium">{selectedDirectory}</p> <p className="break-all text-lg font-medium">{selectedDirectory}</p>
</div> </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"> <div className="inline-flex items-center gap-2">
<input <input
type="radio" type="radio"
@@ -121,20 +123,20 @@ export default function AdminImportPage() {
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <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"> <table className="min-w-full bg-surface text-sm leading-normal text-content">
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-content-muted">
<tr> <tr>
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800"></th> <th className="w-12 border-b border-border p-3 text-left font-normal"></th>
<th className="break-all border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800"> <th className="break-all border-b border-border p-3 text-left font-normal">
{currentPath} {currentPath}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="text-black dark:text-white"> <tbody>
{currentPath !== '/' && ( {currentPath !== '/' && (
<tr> <tr>
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"></td> <td className="border-b border-border p-3 text-content-muted"></td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-border p-3">
<button onClick={handleNavigateUp}> <button onClick={handleNavigateUp}>
<p>../</p> <p>../</p>
</button> </button>
@@ -148,14 +150,14 @@ export default function AdminImportPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
directories.map(item => ( directories.map((item: DirectoryItem) => (
<tr key={item.name}> <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)}> <button onClick={() => item.name && handleSelectDirectory(item.name)}>
<FolderOpenIcon size={20} /> <FolderOpenIcon size={20} />
</button> </button>
</td> </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)}> <button onClick={() => item.name && handleSelectDirectory(item.name)}>
<p>{item.name ?? ''}</p> <p>{item.name ?? ''}</p>
</button> </button>

View File

@@ -1,33 +1,34 @@
import { useGetImportResults } from '../generated/anthoLumeAPIV1'; 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'; import { Link } from 'react-router-dom';
export default function AdminImportResultsPage() { export default function AdminImportResultsPage() {
const { data: resultsData, isLoading } = useGetImportResults(); const { data: resultsData, isLoading } = useGetImportResults();
const results = resultsData?.data?.results || []; const results =
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
if (isLoading) { if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-content-muted">Loading...</div>;
} }
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <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"> <table className="min-w-full bg-surface text-sm leading-normal text-content">
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-content-muted">
<tr> <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 Document
</th> </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 Status
</th> </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 Error
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="text-black dark:text-white"> <tbody>
{results.length === 0 ? ( {results.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={3}> <td className="p-3 text-center" colSpan={3}>
@@ -38,22 +39,24 @@ export default function AdminImportResultsPage() {
results.map((result: ImportResult, index: number) => ( results.map((result: ImportResult, index: number) => (
<tr key={index}> <tr key={index}>
<td <td
className="grid border-b border-gray-200 p-3" className="grid border-b border-border p-3"
style={{ gridTemplateColumns: '4rem auto' }} style={{ gridTemplateColumns: '4rem auto' }}
> >
<span className="text-gray-800 dark:text-gray-400">Name:</span> <span className="text-content-muted">Name:</span>
{result.id ? ( {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>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> <span>{result.path}</span>
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-border p-3">
<p>{result.status}</p> <p>{result.status}</p>
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-border p-3">
<p>{result.error || ''}</p> <p>{result.error || ''}</p>
</td> </td>
</tr> </tr>

View File

@@ -1,39 +1,43 @@
import { useState, FormEvent } from 'react'; import { useState, useEffect, FormEvent } from 'react';
import { useGetLogs } from '../generated/anthoLumeAPIV1'; import { useGetLogs } from '../generated/anthoLumeAPIV1';
import type { LogsResponse } from '../generated/model';
import { Button } from '../components/Button'; 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() { export default function AdminLogsPage() {
const [filter, setFilter] = useState(''); 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) => { const handleFilterSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
refetch(); setActiveFilter(filter);
}; };
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
return ( return (
<div> <div>
{/* Filter Form */} <div className="mb-4 flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<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={handleFilterSubmit}> <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <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">
<SearchIcon size={15} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<input <input
type="text" type="text"
value={filter} value={filter}
onChange={e => setFilter(e.target.value)} 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" placeholder="JQ Filter"
/> />
</div> </div>
@@ -46,16 +50,19 @@ export default function AdminLogsPage() {
</form> </form>
</div> </div>
{/* Log Display */}
<div <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' }} 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"> <span key={index} className="whitespace-nowrap hover:whitespace-pre">
{log} {typeof log === 'string' ? log : JSON.stringify(log)}
</span> </span>
))} ))
)}
</div> </div>
</div> </div>
); );

View File

@@ -12,7 +12,7 @@ interface BackupTypes {
export default function AdminPage() { export default function AdminPage() {
const { isLoading } = useGetAdmin(); const { isLoading } = useGetAdmin();
const postAdminAction = usePostAdminAction(); const postAdminAction = usePostAdminAction();
const { showInfo, showError } = useToasts(); const { showInfo, showError, removeToast } = useToasts();
const [backupTypes, setBackupTypes] = useState<BackupTypes>({ const [backupTypes, setBackupTypes] = useState<BackupTypes>({
covers: false, covers: false,
@@ -42,19 +42,14 @@ export default function AdminPage() {
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`; 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') { if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
try { try {
// Modern browsers: Use File System Access API for direct disk writes
const handle = await window.showSaveFilePicker({ const handle = await window.showSaveFilePicker({
suggestedName: filename, suggestedName: filename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
}); });
const writable = await handle.createWritable(); const writable = await handle.createWritable();
// Stream response body directly to file without buffering
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (!reader) throw new Error('Unable to read response'); if (!reader) throw new Error('Unable to read response');
@@ -67,13 +62,11 @@ export default function AdminPage() {
await writable.close(); await writable.close();
showInfo('Backup completed successfully'); showInfo('Backup completed successfully');
} catch (err) { } catch (err) {
// User cancelled or error
if ((err as Error).name !== 'AbortError') { if ((err as Error).name !== 'AbortError') {
showError('Backup failed: ' + (err as Error).message); showError('Backup failed: ' + (err as Error).message);
} }
} }
} else { } else {
// Fallback for older browsers
showError( showError(
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.' '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(); e.preventDefault();
if (!restoreFile) return; if (!restoreFile) return;
postAdminAction.mutate( const startedToastId = showInfo('Restore started', 0);
{
try {
const response = await postAdminAction.mutateAsync({
data: { data: {
action: 'RESTORE', action: 'RESTORE',
restore_file: restoreFile, restore_file: restoreFile,
}, },
}, });
{
onSuccess: () => { removeToast(startedToastId);
if (response.status >= 200 && response.status < 300) {
showInfo('Restore completed successfully'); showInfo('Restore completed successfully');
}, return;
onError: error => { }
showError('Restore failed: ' + getErrorMessage(error));
}, showError('Restore failed: ' + getErrorMessage(response.data));
} catch (error) {
removeToast(startedToastId);
showError('Restore failed: ' + getErrorMessage(error));
} }
);
}; };
const handleMetadataMatch = () => { const handleMetadataMatch = () => {
postAdminAction.mutate( postAdminAction.mutate(
{ data: { action: 'METADATA_MATCH' } },
{ {
data: { onSuccess: () => showInfo('Metadata matching started'),
action: 'METADATA_MATCH', onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
},
},
{
onSuccess: () => {
showInfo('Metadata matching started');
},
onError: error => {
showError('Metadata matching failed: ' + getErrorMessage(error));
},
} }
); );
}; };
const handleCacheTables = () => { const handleCacheTables = () => {
postAdminAction.mutate( postAdminAction.mutate(
{ data: { action: 'CACHE_TABLES' } },
{ {
data: { onSuccess: () => showInfo('Cache tables started'),
action: 'CACHE_TABLES', onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
},
},
{
onSuccess: () => {
showInfo('Cache tables started');
},
onError: error => {
showError('Cache tables failed: ' + getErrorMessage(error));
},
} }
); );
}; };
if (isLoading) { if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-content-muted">Loading...</div>;
} }
return ( return (
<div className="flex w-full grow flex-col gap-4"> <div className="flex w-full grow flex-col gap-4">
{/* Backup & Restore Card */} <div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<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 text-content">Backup & Restore</p>
<p className="mb-2 text-lg font-semibold">Backup & Restore</p>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Backup Form */} <form className="flex justify-between text-content" onSubmit={handleBackupSubmit}>
<form className="flex justify-between" onSubmit={handleBackupSubmit}>
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div> <div>
<input <input
@@ -180,8 +161,7 @@ export default function AdminPage() {
</div> </div>
</form> </form>
{/* Restore Form */} <form onSubmit={handleRestoreSubmit} className="flex grow justify-between text-content">
<form onSubmit={handleRestoreSubmit} className="flex grow justify-between">
<div className="flex w-1/2 items-center"> <div className="flex w-1/2 items-center">
<input <input
type="file" type="file"
@@ -191,7 +171,7 @@ export default function AdminPage() {
/> />
</div> </div>
<div className="h-10 w-40"> <div className="h-10 w-40">
<Button variant="secondary" type="submit"> <Button variant="secondary" type="submit" disabled={!restoreFile}>
Restore Restore
</Button> </Button>
</div> </div>
@@ -199,11 +179,10 @@ export default function AdminPage() {
</div> </div>
</div> </div>
{/* Tasks Card */} <div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<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 text-content">Tasks</p>
<p className="text-lg font-semibold">Tasks</p> <table className="min-w-full bg-surface text-sm text-content">
<table className="min-w-full bg-white text-sm dark:bg-gray-700"> <tbody>
<tbody className="text-black dark:text-white">
<tr> <tr>
<td className="pl-0"> <td className="pl-0">
<p>Metadata Matching</p> <p>Metadata Matching</p>

View File

@@ -1,5 +1,6 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1'; import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
import type { User, UsersResponse } from '../generated/model';
import { AddIcon, DeleteIcon } from '../icons'; import { AddIcon, DeleteIcon } from '../icons';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
@@ -14,7 +15,7 @@ export default function AdminUsersPage() {
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false); const [newIsAdmin, setNewIsAdmin] = useState(false);
const users = usersData?.data?.users || []; const users = usersData?.status === 200 ? ((usersData.data as UsersResponse).users ?? []) : [];
const handleCreateUser = (e: FormEvent) => { const handleCreateUser = (e: FormEvent) => {
e.preventDefault(); 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'); showInfo('User created successfully');
setShowAddForm(false); setShowAddForm(false);
setNewUsername(''); setNewUsername('');
@@ -38,9 +44,7 @@ export default function AdminUsersPage() {
setNewIsAdmin(false); setNewIsAdmin(false);
refetch(); refetch();
}, },
onError: error => { onError: error => showError('Failed to create user: ' + getErrorMessage(error)),
showError('Failed to create user: ' + getErrorMessage(error));
},
} }
); );
}; };
@@ -48,19 +52,19 @@ export default function AdminUsersPage() {
const handleDeleteUser = (userId: string) => { const handleDeleteUser = (userId: string) => {
updateUser.mutate( updateUser.mutate(
{ {
data: { data: { operation: 'DELETE', user: userId },
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'); showInfo('User deleted successfully');
refetch(); refetch();
}, },
onError: error => { onError: error => showError('Failed to delete user: ' + getErrorMessage(error)),
showError('Failed to delete user: ' + getErrorMessage(error));
},
} }
); );
}; };
@@ -70,20 +74,19 @@ export default function AdminUsersPage() {
updateUser.mutate( updateUser.mutate(
{ {
data: { data: { operation: 'UPDATE', user: userId, password },
operation: 'UPDATE',
user: userId,
password: password,
},
}, },
{ {
onSuccess: () => { onSuccess: response => {
if (response.status < 200 || response.status >= 300) {
showError('Failed to update password: ' + getErrorMessage(response.data));
return;
}
showInfo('Password updated successfully'); showInfo('Password updated successfully');
refetch(); refetch();
}, },
onError: error => { onError: error => showError('Failed to update password: ' + getErrorMessage(error)),
showError('Failed to update password: ' + getErrorMessage(error));
},
} }
); );
}; };
@@ -91,51 +94,45 @@ export default function AdminUsersPage() {
const handleToggleAdmin = (userId: string, isAdmin: boolean) => { const handleToggleAdmin = (userId: string, isAdmin: boolean) => {
updateUser.mutate( updateUser.mutate(
{ {
data: { data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
operation: 'UPDATE',
user: userId,
is_admin: isAdmin,
},
}, },
{ {
onSuccess: () => { onSuccess: response => {
const role = isAdmin ? 'admin' : 'user'; if (response.status < 200 || response.status >= 300) {
showInfo(`User permissions updated to ${role}`); showError('Failed to update admin status: ' + getErrorMessage(response.data));
return;
}
showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`);
refetch(); refetch();
}, },
onError: error => { onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)),
showError('Failed to update admin status: ' + getErrorMessage(error));
},
} }
); );
}; };
if (isLoading) { if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-content-muted">Loading...</div>;
} }
return ( return (
<div className="relative h-full overflow-x-auto"> <div className="relative h-full overflow-x-auto">
{/* Add User Form */}
{showAddForm && ( {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"> <div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
<form <form onSubmit={handleCreateUser} className="flex flex-col gap-2 text-sm text-content">
onSubmit={handleCreateUser}
className="flex flex-col gap-2 text-sm text-black dark:text-white"
>
<input <input
type="text" type="text"
value={newUsername} value={newUsername}
onChange={e => setNewUsername(e.target.value)} onChange={e => setNewUsername(e.target.value)}
placeholder="Username" 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 <input
type="password" type="password"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={e => setNewPassword(e.target.value)}
placeholder="Password" 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"> <div className="flex items-center gap-2">
<input <input
@@ -147,7 +144,7 @@ export default function AdminUsersPage() {
<label htmlFor="new_is_admin">Admin</label> <label htmlFor="new_is_admin">Admin</label>
</div> </div>
<button <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" type="submit"
> >
Create Create
@@ -156,31 +153,26 @@ export default function AdminUsersPage() {
</div> </div>
)} )}
{/* Users Table */}
<div className="min-w-full overflow-scroll rounded shadow"> <div className="min-w-full overflow-scroll rounded shadow">
<table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700"> <table className="min-w-full bg-surface text-sm leading-normal text-content">
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-content-muted">
<tr> <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)}> <button onClick={() => setShowAddForm(!showAddForm)}>
<AddIcon size={20} /> <AddIcon size={20} />
</button> </button>
</th> </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">User</th>
User <th className="border-b border-border p-3 text-left font-normal uppercase">Password</th>
</th> <th className="border-b border-border p-3 text-center font-normal uppercase">
<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">
Permissions Permissions
</th> </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 Created
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="text-black dark:text-white"> <tbody>
{users.length === 0 ? ( {users.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={5}> <td className="p-3 text-center" colSpan={5}>
@@ -188,39 +180,35 @@ export default function AdminUsersPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
users.map(user => ( users.map((user: User) => (
<tr key={user.id}> <tr key={user.id}>
{/* Delete Button */} <td className="relative cursor-pointer border-b border-border p-3 text-content-muted">
<td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
<button onClick={() => handleDeleteUser(user.id)}> <button onClick={() => handleDeleteUser(user.id)}>
<DeleteIcon size={20} /> <DeleteIcon size={20} />
</button> </button>
</td> </td>
{/* User ID */} <td className="border-b border-border p-3">
<td className="border-b border-gray-200 p-3">
<p>{user.id}</p> <p>{user.id}</p>
</td> </td>
{/* Password Reset */} <td className="border-b border-border px-3">
<td className="border-b border-gray-200 px-3">
<button <button
onClick={() => { onClick={() => {
const password = prompt(`Enter new password for ${user.id}`); const password = prompt(`Enter new password for ${user.id}`);
if (password) handleUpdatePassword(user.id, password); 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 Reset
</button> </button>
</td> </td>
{/* Admin Toggle */} <td className="flex min-w-40 justify-center gap-2 border-b border-border p-3 text-center">
<td className="flex min-w-40 justify-center gap-2 border-b border-gray-200 p-3 text-center">
<button <button
onClick={() => handleToggleAdmin(user.id, true)} onClick={() => handleToggleAdmin(user.id, true)}
disabled={user.admin} disabled={user.admin}
className={`rounded-md px-2 py-1 text-white dark:text-black ${ className={`rounded-md px-2 py-1 ${
user.admin user.admin
? 'cursor-default bg-gray-800 dark:bg-gray-100' ? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-gray-400 dark:bg-gray-600' : 'cursor-pointer bg-surface-strong text-content'
}`} }`}
> >
admin admin
@@ -228,17 +216,16 @@ export default function AdminUsersPage() {
<button <button
onClick={() => handleToggleAdmin(user.id, false)} onClick={() => handleToggleAdmin(user.id, false)}
disabled={!user.admin} disabled={!user.admin}
className={`rounded-md px-2 py-1 text-white dark:text-black ${ className={`rounded-md px-2 py-1 ${
!user.admin !user.admin
? 'cursor-default bg-gray-800 dark:bg-gray-100' ? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-gray-400 dark:bg-gray-600' : 'cursor-pointer bg-surface-strong text-content'
}`} }`}
> >
user user
</button> </button>
</td> </td>
{/* Created Date */} <td className="border-b border-border p-3">
<td className="border-b border-gray-200 p-3">
<p>{user.created_at}</p> <p>{user.created_at}</p>
</td> </td>
</tr> </tr>

View File

@@ -38,17 +38,16 @@ export default function ComponentDemoPage() {
}; };
return ( return (
<div className="space-y-8 p-4"> <div className="space-y-8 p-4 text-content">
<h1 className="text-2xl font-bold dark:text-white">UI Components Demo</h1> <h1 className="text-2xl font-bold">UI Components Demo</h1>
{/* Toast Demos */} <section className="rounded-lg bg-surface p-6 shadow">
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700"> <h2 className="mb-4 text-xl font-semibold">Toast Notifications</h2>
<h2 className="mb-4 text-xl font-semibold dark:text-white">Toast Notifications</h2>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<button <button
onClick={handleDemoClick} onClick={handleDemoClick}
disabled={isLoading} 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'} {isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
</button> </button>
@@ -66,21 +65,19 @@ export default function ComponentDemoPage() {
</button> </button>
<button <button
onClick={handleCustomToast} 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 Show Custom Toast
</button> </button>
</div> </div>
</section> </section>
{/* Skeleton Demos */} <section className="rounded-lg bg-surface p-6 shadow">
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700"> <h2 className="mb-4 text-xl font-semibold">Skeleton Loading Components</h2>
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Loading Components</h2>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2"> <div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Basic Skeletons */}
<div className="space-y-4"> <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"> <div className="space-y-2">
<Skeleton className="h-8 w-full" /> <Skeleton className="h-8 w-full" />
<Skeleton variant="text" className="w-3/4" /> <Skeleton variant="text" className="w-3/4" />
@@ -92,16 +89,14 @@ export default function ComponentDemoPage() {
</div> </div>
</div> </div>
{/* Skeleton Text */}
<div className="space-y-4"> <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={3} />
<SkeletonText lines={5} className="max-w-md" /> <SkeletonText lines={5} className="max-w-md" />
</div> </div>
{/* Skeleton Avatar */}
<div className="space-y-4"> <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"> <div className="flex items-center gap-4">
<SkeletonAvatar size="sm" /> <SkeletonAvatar size="sm" />
<SkeletonAvatar size="md" /> <SkeletonAvatar size="md" />
@@ -110,9 +105,8 @@ export default function ComponentDemoPage() {
</div> </div>
</div> </div>
{/* Skeleton Button */}
<div className="space-y-4"> <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"> <div className="flex flex-wrap gap-2">
<SkeletonButton width={120} /> <SkeletonButton width={120} />
<SkeletonButton className="w-full max-w-xs" /> <SkeletonButton className="w-full max-w-xs" />
@@ -121,9 +115,8 @@ export default function ComponentDemoPage() {
</div> </div>
</section> </section>
{/* Skeleton Card Demo */} <section className="rounded-lg bg-surface p-6 shadow">
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700"> <h2 className="mb-4 text-xl font-semibold">Skeleton Cards</h2>
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Cards</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<SkeletonCard /> <SkeletonCard />
<SkeletonCard showAvatar /> <SkeletonCard showAvatar />
@@ -131,33 +124,30 @@ export default function ComponentDemoPage() {
</div> </div>
</section> </section>
{/* Skeleton Table Demo */} <section className="rounded-lg bg-surface p-6 shadow">
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700"> <h2 className="mb-4 text-xl font-semibold">Skeleton Table</h2>
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Table</h2>
<SkeletonTable rows={5} columns={4} /> <SkeletonTable rows={5} columns={4} />
</section> </section>
{/* Page Loader Demo */} <section className="rounded-lg bg-surface p-6 shadow">
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700"> <h2 className="mb-4 text-xl font-semibold">Page Loader</h2>
<h2 className="mb-4 text-xl font-semibold dark:text-white">Page Loader</h2>
<PageLoader message="Loading demo content..." /> <PageLoader message="Loading demo content..." />
</section> </section>
{/* Inline Loader Demo */} <section className="rounded-lg bg-surface p-6 shadow">
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700"> <h2 className="mb-4 text-xl font-semibold">Inline Loader</h2>
<h2 className="mb-4 text-xl font-semibold dark:text-white">Inline Loader</h2>
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="text-center"> <div className="text-center">
<InlineLoader size="sm" /> <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>
<div className="text-center"> <div className="text-center">
<InlineLoader size="md" /> <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>
<div className="text-center"> <div className="text-center">
<InlineLoader size="lg" /> <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>
</div> </div>
</section> </section>

View File

@@ -1,12 +1,12 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { import {
useGetDocument, useGetDocument,
useEditDocument, useEditDocument,
getGetDocumentQueryKey, getGetDocumentQueryKey,
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import { Document } from '../generated/model/document'; import { Document } from '../generated/model/document';
import { Progress } from '../generated/model/progress';
import { useQueryClient } from '@tanstack/react-query';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { import {
DeleteIcon, DeleteIcon,
@@ -18,9 +18,14 @@ import {
CloseIcon, CloseIcon,
CheckIcon, CheckIcon,
} from '../icons'; } from '../icons';
import { useState } from 'react';
import { Field, FieldLabel, FieldValue, FieldActions } from '../components'; 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() { export default function DocumentPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -35,51 +40,40 @@ export default function DocumentPage() {
const [isEditingDescription, setIsEditingDescription] = useState(false); const [isEditingDescription, setIsEditingDescription] = useState(false);
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false); const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
// Edit values - initialized after document is loaded
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editAuthor, setEditAuthor] = useState(''); const [editAuthor, setEditAuthor] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
if (docLoading) { 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) { 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 document = docData.data.document as Document;
const progress =
docData?.status === 200 ? (docData.data.progress as Progress | undefined) : undefined;
if (!document) { 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 = const percentage = document.percentage ?? 0;
document.percentage ?? (progress?.percentage ? progress.percentage * 100 : 0) ?? 0;
const secondsPerPercent = document.seconds_per_percent || 0; const secondsPerPercent = document.seconds_per_percent || 0;
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent); const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
// Helper to start editing
const startEditing = (field: 'title' | 'author' | 'description') => { const startEditing = (field: 'title' | 'author' | 'description') => {
if (field === 'title') setEditTitle(document.title); if (field === 'title') setEditTitle(document.title);
if (field === 'author') setEditAuthor(document.author); if (field === 'author') setEditAuthor(document.author);
if (field === 'description') setEditDescription(document.description || ''); if (field === 'description') setEditDescription(document.description || '');
}; };
// Save edit handlers
const saveTitle = () => { const saveTitle = () => {
editMutation.mutate( editMutation.mutate(
{ { id: document.id, data: { title: editTitle } },
id: document.id,
data: { title: editTitle },
},
{ {
onSuccess: response => { onSuccess: response => {
setIsEditingTitle(false); setIsEditingTitle(false);
// Update cache with the response data (no refetch needed)
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response); queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
}, },
onError: () => setIsEditingTitle(false), onError: () => setIsEditingTitle(false),
@@ -89,14 +83,10 @@ export default function DocumentPage() {
const saveAuthor = () => { const saveAuthor = () => {
editMutation.mutate( editMutation.mutate(
{ { id: document.id, data: { author: editAuthor } },
id: document.id,
data: { author: editAuthor },
},
{ {
onSuccess: response => { onSuccess: response => {
setIsEditingAuthor(false); setIsEditingAuthor(false);
// Update cache with the response data (no refetch needed)
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response); queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
}, },
onError: () => setIsEditingAuthor(false), onError: () => setIsEditingAuthor(false),
@@ -106,14 +96,10 @@ export default function DocumentPage() {
const saveDescription = () => { const saveDescription = () => {
editMutation.mutate( editMutation.mutate(
{ { id: document.id, data: { description: editDescription } },
id: document.id,
data: { description: editDescription },
},
{ {
onSuccess: response => { onSuccess: response => {
setIsEditingDescription(false); setIsEditingDescription(false);
// Update cache with the response data (no refetch needed)
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response); queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
}, },
onError: () => setIsEditingDescription(false), onError: () => setIsEditingDescription(false),
@@ -123,10 +109,8 @@ export default function DocumentPage() {
return ( return (
<div className="relative size-full"> <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"> <div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
{/* Document Info - Left Column */}
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80"> <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"> <label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
<img <img
className="w-full rounded object-fill" className="w-full rounded object-fill"
@@ -135,31 +119,27 @@ export default function DocumentPage() {
/> />
</label> </label>
{/* Read Button - Only if file exists */}
{document.filepath && ( {document.filepath && (
<a <a
href={`/reader#id=${document.id}&type=REMOTE`} href={`/reader/${document.id}`}
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" 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 Read
</a> </a>
)} )}
{/* Action Buttons Container */}
<div className="relative z-20 flex flex-wrap-reverse justify-between gap-2"> <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="min-w-[50%] md:mr-2">
<div className="flex gap-1 text-sm"> <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> <p className="font-medium">{document.isbn10 || 'N/A'}</p>
</div> </div>
<div className="flex gap-1 text-sm"> <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> <p className="font-medium">{document.isbn13 || 'N/A'}</p>
</div> </div>
</div> </div>
{/* Edit Cover Dropdown */}
<div className="relative"> <div className="relative">
<input <input
type="checkbox" type="checkbox"
@@ -169,35 +149,24 @@ export default function DocumentPage() {
onChange={e => setShowEditCover(e.target.checked)} onChange={e => setShowEditCover(e.target.checked)}
/> />
<div <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' showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
}`} }`}
> >
<form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white"> <form className="flex w-72 flex-col gap-2 text-sm">
<input <input type="file" id="cover_file" name="cover_file" className={popupInputClassName} />
type="file"
id="cover_file"
name="cover_file"
className="bg-gray-300 p-2"
/>
<button <button
type="submit" 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 Upload Cover
</button> </button>
</form> </form>
<form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white"> <form className="flex w-72 flex-col gap-2 text-sm">
<input <input type="checkbox" checked id="remove_cover" name="remove_cover" className="hidden" />
type="checkbox"
checked
id="remove_cover"
name="remove_cover"
className="hidden"
/>
<button <button
type="submit" 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 Remove Cover
</button> </button>
@@ -205,24 +174,22 @@ export default function DocumentPage() {
</div> </div>
</div> </div>
{/* Icons Container */} <div className="relative my-auto flex grow justify-between text-content-muted">
<div className="relative my-auto flex grow justify-between text-gray-500 dark:text-gray-500">
{/* Delete Button */}
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => setShowDelete(!showDelete)} onClick={() => setShowDelete(!showDelete)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100" className={iconButtonClassName}
aria-label="Delete" aria-label="Delete"
> >
<DeleteIcon size={28} /> <DeleteIcon size={28} />
</button> </button>
<div <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' 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 <button
type="submit" type="submit"
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700" 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>
</div> </div>
{/* Activity Button */}
<a <a
href={`/activity?document=${document.id}`} href={`/activity?document=${document.id}`}
aria-label="Activity" aria-label="Activity"
className="hover:text-gray-800 dark:hover:text-gray-100" className={iconButtonClassName}
> >
<ActivityIcon size={28} /> <ActivityIcon size={28} />
</a> </a>
{/* Identify/Search Button */}
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => setShowIdentify(!showIdentify)} onClick={() => setShowIdentify(!showIdentify)}
aria-label="Identify" aria-label="Identify"
className="hover:text-gray-800 dark:hover:text-gray-100" className={iconButtonClassName}
> >
<SearchIcon size={28} /> <SearchIcon size={28} />
</button> </button>
<div <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' 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 <input
type="text" type="text"
id="title" id="title"
name="title" name="title"
placeholder="Title" placeholder="Title"
defaultValue={document.title} defaultValue={document.title}
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white" className={popupInputClassName}
/> />
<input <input
type="text" type="text"
@@ -272,7 +237,7 @@ export default function DocumentPage() {
name="author" name="author"
placeholder="Author" placeholder="Author"
defaultValue={document.author} defaultValue={document.author}
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white" className={popupInputClassName}
/> />
<input <input
type="text" type="text"
@@ -280,11 +245,11 @@ export default function DocumentPage() {
name="isbn" name="isbn"
placeholder="ISBN 10 / ISBN 13" placeholder="ISBN 10 / ISBN 13"
defaultValue={document.isbn13 || document.isbn10} defaultValue={document.isbn13 || document.isbn10}
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white" className={popupInputClassName}
/> />
<button <button
type="submit" 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 Identify
</button> </button>
@@ -292,17 +257,16 @@ export default function DocumentPage() {
</div> </div>
</div> </div>
{/* Download Button */}
{document.filepath ? ( {document.filepath ? (
<a <a
href={`/api/v1/documents/${document.id}/file`} href={`/api/v1/documents/${document.id}/file`}
aria-label="Download" aria-label="Download"
className="hover:text-gray-800 dark:hover:text-gray-100" className={iconButtonClassName}
> >
<DownloadIcon size={28} /> <DownloadIcon size={28} />
</a> </a>
) : ( ) : (
<span className="text-gray-200 dark:text-gray-600"> <span className="text-content-subtle">
<DownloadIcon size={28} disabled /> <DownloadIcon size={28} disabled />
</span> </span>
)} )}
@@ -310,9 +274,7 @@ export default function DocumentPage() {
</div> </div>
</div> </div>
{/* Document Details Grid */}
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2"> <div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
{/* Title - Editable */}
<Field <Field
isEditing={isEditingTitle} isEditing={isEditingTitle}
label={ label={
@@ -321,20 +283,10 @@ export default function DocumentPage() {
<FieldActions> <FieldActions>
{isEditingTitle ? ( {isEditingTitle ? (
<div className="flex gap-1"> <div className="flex gap-1">
<button <button type="button" onClick={() => setIsEditingTitle(false)} className={iconButtonClassName} aria-label="Cancel edit">
type="button"
onClick={() => setIsEditingTitle(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<CloseIcon size={18} /> <CloseIcon size={18} />
</button> </button>
<button <button type="button" onClick={saveTitle} className={iconButtonClassName} aria-label="Confirm edit">
type="button"
onClick={saveTitle}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<CheckIcon size={18} /> <CheckIcon size={18} />
</button> </button>
</div> </div>
@@ -345,7 +297,7 @@ export default function DocumentPage() {
startEditing('title'); startEditing('title');
setIsEditingTitle(true); setIsEditingTitle(true);
}} }}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100" className={iconButtonClassName}
aria-label="Edit title" aria-label="Edit title"
> >
<EditIcon size={18} /> <EditIcon size={18} />
@@ -357,19 +309,13 @@ export default function DocumentPage() {
> >
{isEditingTitle ? ( {isEditingTitle ? (
<div className="relative mt-1 flex gap-2"> <div className="relative mt-1 flex gap-2">
<input <input type="text" value={editTitle} onChange={e => setEditTitle(e.target.value)} className={editInputClassName} />
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"
/>
</div> </div>
) : ( ) : (
<FieldValue>{document.title}</FieldValue> <FieldValue>{document.title}</FieldValue>
)} )}
</Field> </Field>
{/* Author - Editable */}
<Field <Field
isEditing={isEditingAuthor} isEditing={isEditingAuthor}
label={ label={
@@ -378,20 +324,10 @@ export default function DocumentPage() {
<FieldActions> <FieldActions>
{isEditingAuthor ? ( {isEditingAuthor ? (
<> <>
<button <button type="button" onClick={() => setIsEditingAuthor(false)} className={iconButtonClassName} aria-label="Cancel edit">
type="button"
onClick={() => setIsEditingAuthor(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<CloseIcon size={18} /> <CloseIcon size={18} />
</button> </button>
<button <button type="button" onClick={saveAuthor} className={iconButtonClassName} aria-label="Confirm edit">
type="button"
onClick={saveAuthor}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<CheckIcon size={18} /> <CheckIcon size={18} />
</button> </button>
</> </>
@@ -402,7 +338,7 @@ export default function DocumentPage() {
startEditing('author'); startEditing('author');
setIsEditingAuthor(true); setIsEditingAuthor(true);
}} }}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100" className={iconButtonClassName}
aria-label="Edit author" aria-label="Edit author"
> >
<EditIcon size={18} /> <EditIcon size={18} />
@@ -414,19 +350,13 @@ export default function DocumentPage() {
> >
{isEditingAuthor ? ( {isEditingAuthor ? (
<div className="relative mt-1 flex gap-2"> <div className="relative mt-1 flex gap-2">
<input <input type="text" value={editAuthor} onChange={e => setEditAuthor(e.target.value)} className={editInputClassName} />
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"
/>
</div> </div>
) : ( ) : (
<FieldValue>{document.author}</FieldValue> <FieldValue>{document.author}</FieldValue>
)} )}
</Field> </Field>
{/* Time Read with Info Dropdown */}
<Field <Field
label={ label={
<> <>
@@ -434,31 +364,27 @@ export default function DocumentPage() {
<button <button
type="button" type="button"
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)} 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" aria-label="Show time read info"
> >
<InfoIcon size={18} /> <InfoIcon size={18} />
</button> </button>
<div <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' showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
}`} }`}
> >
<div className="flex text-xs"> <div className="flex text-xs">
<p className="w-32 text-gray-400">Seconds / Percent</p> <p className="w-32 text-content-subtle">Seconds / Percent</p>
<p className="font-medium dark:text-white"> <p className="font-medium">{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}</p>
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
</p>
</div> </div>
<div className="flex text-xs"> <div className="flex text-xs">
<p className="w-32 text-gray-400">Words / Minute</p> <p className="w-32 text-content-subtle">Words / Minute</p>
<p className="font-medium dark:text-white"> <p className="font-medium">{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}</p>
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
</p>
</div> </div>
<div className="flex text-xs"> <div className="flex text-xs">
<p className="w-32 text-gray-400">Est. Time Left</p> <p className="w-32 text-content-subtle">Est. Time Left</p>
<p className="whitespace-nowrap font-medium dark:text-white"> <p className="whitespace-nowrap font-medium">
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'} {totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
</p> </p>
</div> </div>
@@ -473,13 +399,11 @@ export default function DocumentPage() {
</FieldValue> </FieldValue>
</Field> </Field>
{/* Progress */}
<Field label={<FieldLabel>Progress</FieldLabel>}> <Field label={<FieldLabel>Progress</FieldLabel>}>
<FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue> <FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue>
</Field> </Field>
</div> </div>
{/* Description - Editable */}
<Field <Field
isEditing={isEditingDescription} isEditing={isEditingDescription}
label={ label={
@@ -488,20 +412,10 @@ export default function DocumentPage() {
<FieldActions> <FieldActions>
{isEditingDescription ? ( {isEditingDescription ? (
<> <>
<button <button type="button" onClick={() => setIsEditingDescription(false)} className={iconButtonClassName} aria-label="Cancel edit">
type="button"
onClick={() => setIsEditingDescription(false)}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Cancel edit"
>
<CloseIcon size={18} /> <CloseIcon size={18} />
</button> </button>
<button <button type="button" onClick={saveDescription} className={iconButtonClassName} aria-label="Confirm edit">
type="button"
onClick={saveDescription}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
aria-label="Confirm edit"
>
<CheckIcon size={18} /> <CheckIcon size={18} />
</button> </button>
</> </>
@@ -512,7 +426,7 @@ export default function DocumentPage() {
startEditing('description'); startEditing('description');
setIsEditingDescription(true); setIsEditingDescription(true);
}} }}
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100" className={iconButtonClassName}
aria-label="Edit description" aria-label="Edit description"
> >
<EditIcon size={18} /> <EditIcon size={18} />
@@ -527,14 +441,12 @@ export default function DocumentPage() {
<textarea <textarea
value={editDescription} value={editDescription}
onChange={e => setEditDescription(e.target.value)} 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} rows={5}
/> />
</div> </div>
) : ( ) : (
<FieldValue className="hyphens-auto text-justify"> <FieldValue className="hyphens-auto text-justify">{document.description || 'N/A'}</FieldValue>
{document.description || 'N/A'}
</FieldValue>
)} )}
</Field> </Field>
</div> </div>

View File

@@ -1,66 +1,140 @@
import { useState, FormEvent, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document, DocumentsResponse } from '../generated/model'; import type { Document, DocumentsResponse } from '../generated/model';
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { Button } from '../components/Button'; import { LoadingState } from '../components';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import {
getDocumentsViewMode,
setDocumentsViewMode,
type DocumentsViewMode,
} from '../utils/localSettings';
interface DocumentCardProps { interface DocumentCardProps {
doc: Document; doc: Document;
} }
function DocumentCard({ doc }: DocumentCardProps) { function DocumentCard({ doc }: DocumentCardProps) {
const navigate = useNavigate();
const percentage = doc.percentage || 0; const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0; const totalTimeSeconds = doc.total_time_seconds || 0;
return ( return (
<div className="relative w-full"> <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"> <div className="relative my-auto h-48 min-w-fit">
<Link to={`/documents/${doc.id}`}>
<img <img
className="h-full rounded object-cover" className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`} src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title} alt={doc.title}
/> />
</Link>
</div> </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 className="inline-flex shrink-0 items-center">
<div> <div>
<p className="text-gray-400">Title</p> <p className="text-content-subtle">Title</p>
<p className="font-medium">{doc.title || 'Unknown'}</p> <p className="font-medium">{doc.title || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="inline-flex shrink-0 items-center"> <div className="inline-flex shrink-0 items-center">
<div> <div>
<p className="text-gray-400">Author</p> <p className="text-content-subtle">Author</p>
<p className="font-medium">{doc.author || 'Unknown'}</p> <p className="font-medium">{doc.author || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="inline-flex shrink-0 items-center"> <div className="inline-flex shrink-0 items-center">
<div> <div>
<p className="text-gray-400">Progress</p> <p className="text-content-subtle">Progress</p>
<p className="font-medium">{percentage}%</p> <p className="font-medium">{percentage}%</p>
</div> </div>
</div> </div>
<div className="inline-flex shrink-0 items-center"> <div className="inline-flex shrink-0 items-center">
<div> <div>
<p className="text-gray-400">Time Read</p> <p className="text-content-subtle">Time Read</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p> <p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400"> <div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
<Link to={`/activity?document=${doc.id}`}> <Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} /> <ActivityIcon size={20} />
</Link> </Link>
{doc.filepath ? ( {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} /> <DownloadIcon size={20} />
</a> </a>
) : ( ) : (
@@ -77,12 +151,16 @@ export default function DocumentsPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [limit] = useState(9); const [limit] = useState(9);
const [uploadMode, setUploadMode] = useState(false); const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showInfo, showWarning, showError } = useToasts(); const { showInfo, showWarning, showError } = useToasts();
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
// Reset to page 1 when search changes useEffect(() => {
setDocumentsViewMode(viewMode);
}, [viewMode]);
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [debouncedSearch]); }, [debouncedSearch]);
@@ -93,11 +171,6 @@ export default function DocumentsPage() {
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page; const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_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 handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Search Form */} <div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
<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="flex flex-col gap-4 lg:flex-row">
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <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">
<SearchIcon size={15} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<input <input
type="text" type="text"
value={search} value={search}
onChange={e => setSearch(e.target.value)} 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" placeholder="Search Author / Title"
name="search" name="search"
/> />
</div> </div>
</div> </div>
<div className="lg:w-60"> <div className="inline-flex rounded border border-border bg-surface p-1">
<Button variant="secondary" type="submit"> <button
Search type="button"
</Button> onClick={() => setViewMode('grid')}
className={getViewModeButtonClasses('grid')}
>
Grid
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className={getViewModeButtonClasses('list')}
>
List
</button>
</div>
</div> </div>
</form>
</div> </div>
{/* Document Grid */} {viewMode === 'grid' ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{isLoading ? ( {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>
) : (
<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-content">
<div className="mt-4 flex w-full justify-center gap-4 text-black dark:text-white">
{previousPage && previousPage > 0 && ( {previousPage && previousPage > 0 && (
<button <button
onClick={() => setPage(page - 1)} 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> </button>
@@ -178,14 +284,13 @@ export default function DocumentsPage() {
{nextPage && nextPage > 0 && ( {nextPage && nextPage > 0 && (
<button <button
onClick={() => setPage(page + 1)} 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> </button>
)} )}
</div> </div>
{/* Upload Button */}
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full"> <div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
<input <input
type="checkbox" type="checkbox"
@@ -195,7 +300,7 @@ export default function DocumentsPage() {
onChange={() => setUploadMode(!uploadMode)} onChange={() => setUploadMode(!uploadMode)}
/> />
<div <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"> <form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
<input <input
@@ -207,7 +312,7 @@ export default function DocumentsPage() {
onChange={handleFileChange} onChange={handleFileChange}
/> />
<button <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" type="submit"
onClick={e => { onClick={e => {
e.preventDefault(); e.preventDefault();
@@ -221,7 +326,7 @@ export default function DocumentsPage() {
</form> </form>
<label htmlFor="upload-file-button"> <label htmlFor="upload-file-button">
<div <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} onClick={handleCancelUpload}
> >
Cancel Upload Cancel Upload
@@ -229,10 +334,10 @@ export default function DocumentsPage() {
</label> </label>
</div> </div>
<label <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" htmlFor="upload-file-button"
> >
<UploadIcon size={34} /> <UploadIcon size={34} className="text-content-inverse" />
</label> </label>
</div> </div>
</div> </div>

View File

@@ -17,29 +17,24 @@ interface InfoCardProps {
} }
function InfoCard({ title, size, link }: 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) { if (link) {
return ( return (
<Link to={link} className="w-full"> <Link to={link} className="w-full">
<div className="flex w-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700"> {content}
<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>
</Link> </Link>
); );
} }
return ( return <div className="w-full">{content}</div>;
<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>
);
} }
interface StreakCardProps { interface StreakCardProps {
@@ -63,18 +58,18 @@ function StreakCard({
}: StreakCardProps) { }: StreakCardProps) {
return ( return (
<div className="w-full"> <div className="w-full">
<div className="relative w-full rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700"> <div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
<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">
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'} {window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
</p> </p>
<div className="my-6 flex items-end space-x-2"> <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>
<div className="dark:text-white"> <div>
<div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2 text-sm"> <div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
<div> <div>
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p> <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} {currentStreakStartDate} {currentStreakEndDate}
</div> </div>
</div> </div>
@@ -83,7 +78,7 @@ function StreakCard({
<div className="mb-2 flex items-center justify-between pb-2 text-sm"> <div className="mb-2 flex items-center justify-between pb-2 text-sm">
<div> <div>
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p> <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} {maxStreakStartDate} {maxStreakEndDate}
</div> </div>
</div> </div>
@@ -120,67 +115,47 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
const currentData = data[selectedPeriod]; const currentData = data[selectedPeriod];
const handlePeriodChange = (period: TimePeriod) => { const getPeriodClassName = (period: TimePeriod) =>
setSelectedPeriod(period); `cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
};
return ( return (
<div className="w-full"> <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>
<div className="flex justify-between"> <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 {name} Leaderboard
</p> </p>
<div className="flex items-center gap-2 text-xs text-gray-400"> <div className="flex items-center gap-2 text-xs">
<button <button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
type="button"
onClick={() => handlePeriodChange('all')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'all' ? '!text-black dark:!text-white' : ''}`}
>
all all
</button> </button>
<button <button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
type="button"
onClick={() => handlePeriodChange('year')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'year' ? '!text-black dark:!text-white' : ''}`}
>
year year
</button> </button>
<button <button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
type="button"
onClick={() => handlePeriodChange('month')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'month' ? '!text-black dark:!text-white' : ''}`}
>
month month
</button> </button>
<button <button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
type="button"
onClick={() => handlePeriodChange('week')}
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'week' ? '!text-black dark:!text-white' : ''}`}
>
week week
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Current period data */}
<div className="my-6 flex items-end space-x-2"> <div className="my-6 flex items-end space-x-2">
{currentData?.length === 0 ? ( {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"> <p className="text-5xl font-bold">{currentData[0]?.user_id || 'N/A'}</p>
{currentData[0]?.user_id || 'N/A'}
</p>
)} )}
</div> </div>
<div className="dark:text-white"> <div>
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => ( {currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
<div <div
key={index} 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> <div>
<p>{item.user_id}</p> <p>{item.user_id}</p>
@@ -204,22 +179,20 @@ export default function HomePage() {
const userStats = homeResponse?.user_statistics; const userStats = homeResponse?.user_statistics;
if (homeLoading) { if (homeLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-content-muted">Loading...</div>;
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Daily Read Totals Graph */}
<div className="w-full"> <div className="w-full">
<div className="relative w-full rounded bg-white shadow-lg dark:bg-gray-700"> <div className="relative w-full rounded bg-surface shadow-lg">
<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"> <p className="absolute left-5 top-3 w-max border-b border-border text-sm font-semibold text-content-muted">
Daily Read Totals Daily Read Totals
</p> </p>
<ReadingHistoryGraph data={graphData || []} /> <ReadingHistoryGraph data={graphData || []} />
</div> </div>
</div> </div>
{/* Info Cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<InfoCard title="Documents" size={dbInfo?.documents_size || 0} link="./documents" /> <InfoCard title="Documents" size={dbInfo?.documents_size || 0} link="./documents" />
<InfoCard title="Activity Records" size={dbInfo?.activity_size || 0} link="./activity" /> <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} /> <InfoCard title="Devices" size={dbInfo?.devices_size || 0} />
</div> </div>
{/* Streak Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{streaks?.map((streak: UserStreak, index: number) => ( {streaks?.map((streak: UserStreak, index: number) => (
<StreakCard <StreakCard
@@ -243,7 +215,6 @@ export default function HomePage() {
))} ))}
</div> </div>
{/* Leaderboard Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<LeaderboardCard <LeaderboardCard
name="WPM" name="WPM"

View 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();
});
});

View File

@@ -5,58 +5,58 @@ import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1'; import { useGetInfo } from '../generated/anthoLumeAPIV1';
export default function LoginPage() { interface LoginPageViewProps {
const [username, setUsername] = useState(''); username: string;
const [password, setPassword] = useState(''); password: string;
const [isLoading, setIsLoading] = useState(false); isLoading: boolean;
registrationEnabled: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<void>;
}
const { login, isAuthenticated, isCheckingAuth } = useAuth(); export function getRegistrationEnabled(infoData: unknown): boolean {
const navigate = useNavigate(); if (!infoData || typeof infoData !== 'object') {
const { showError } = useToasts(); return false;
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 });
} }
}, [isAuthenticated, isCheckingAuth, navigate]);
const handleSubmit = async (e: FormEvent) => { if (!('data' in infoData) || !infoData.data || typeof infoData.data !== 'object') {
e.preventDefault(); return false;
setIsLoading(true);
try {
await login(username, password);
} catch (_err) {
showError('Invalid credentials');
} finally {
setIsLoading(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 ( 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-wrap">
<div className="flex w-full flex-col md:w-1/2"> <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"> <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> <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="flex flex-col pt-4">
<div className="relative flex"> <div className="relative flex">
<input <input
type="text" type="text"
value={username} value={username}
onChange={e => setUsername(e.target.value)} onChange={e => onUsernameChange(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" placeholder="Username"
required required
disabled={isLoading} disabled={isLoading}
@@ -68,8 +68,8 @@ export default function LoginPage() {
<input <input
type="password" type="password"
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => onPasswordChange(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" placeholder="Password"
required required
disabled={isLoading} disabled={isLoading}
@@ -103,11 +103,59 @@ export default function LoginPage() {
</div> </div>
</div> </div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block"> <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"> <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-gray-500">AnthoLume</span> <span className="text-content-muted">AnthoLume</span>
</div> </div>
</div> </div>
</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}
/>
);
}

View File

@@ -1,21 +1,18 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetProgressList } from '../generated/anthoLumeAPIV1'; import { useGetProgressList } from '../generated/anthoLumeAPIV1';
import type { Progress } from '../generated/model'; import type { Progress } from '../generated/model';
import { Table } from '../components/Table'; import { Table, type Column } from '../components/Table';
export default function ProgressPage() { export default function ProgressPage() {
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 }); const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
const progress = data?.status === 200 ? (data.data.progress ?? []) : []; const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
const columns = [ const columns: Column<Progress>[] = [
{ {
key: 'document_id' as const, key: 'document_id' as const,
header: 'Document', header: 'Document',
render: (_value: Progress['document_id'], row: Progress) => ( render: (_value, row) => (
<Link <Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
to={`/documents/${row.document_id}`}
className="text-blue-600 hover:underline dark:text-blue-400"
>
{row.author || 'Unknown'} - {row.title || 'Unknown'} {row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link> </Link>
), ),
@@ -23,18 +20,18 @@ export default function ProgressPage() {
{ {
key: 'device_name' as const, key: 'device_name' as const,
header: 'Device Name', header: 'Device Name',
render: (value: Progress['device_name']) => value || 'Unknown', render: value => String(value || 'Unknown'),
}, },
{ {
key: 'percentage' as const, key: 'percentage' as const,
header: 'Percentage', 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, key: 'created_at' as const,
header: 'Created At', header: 'Created At',
render: (value: Progress['created_at']) => render: value =>
value ? new Date(value).toLocaleDateString() : 'N/A', typeof value === 'string' && value ? new Date(value).toLocaleDateString() : 'N/A',
}, },
]; ];

View 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>
);
}

View 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();
});
});

View File

@@ -49,7 +49,7 @@ export default function RegisterPage() {
}; };
return ( 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-wrap">
<div className="flex w-full flex-col md:w-1/2"> <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"> <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" type="text"
value={username} value={username}
onChange={e => setUsername(e.target.value)} 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" placeholder="Username"
required required
disabled={isLoading || isLoadingInfo || !registrationEnabled} disabled={isLoading || isLoadingInfo || !registrationEnabled}
@@ -74,7 +74,7 @@ export default function RegisterPage() {
type="password" type="password"
value={password} value={password}
onChange={e => setPassword(e.target.value)} 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" placeholder="Password"
required required
disabled={isLoading || isLoadingInfo || !registrationEnabled} disabled={isLoading || isLoadingInfo || !registrationEnabled}
@@ -106,8 +106,8 @@ export default function RegisterPage() {
</div> </div>
</div> </div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block"> <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"> <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-gray-500">AnthoLume</span> <span className="text-content-muted">AnthoLume</span>
</div> </div>
</div> </div>
</div> </div>

View 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,
},
},
);
});
});

View File

@@ -1,53 +1,81 @@
import { useState, FormEvent } from 'react'; import { useState, useEffect, FormEvent } from 'react';
import { useGetSearch } from '../generated/anthoLumeAPIV1'; import { useGetSearch } from '../generated/anthoLumeAPIV1';
import { GetSearchSource } from '../generated/model/getSearchSource'; import { GetSearchSource } from '../generated/model/getSearchSource';
import type { SearchItem } from '../generated/model'; import type { SearchItem } from '../generated/model';
import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { LoadingState } from '../components';
import { useDebounce } from '../hooks/useDebounce';
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
export default function SearchPage() { interface SearchPageViewProps {
const [query, setQuery] = useState(''); query: string;
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen); 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 }); export function getSearchResults(data: unknown): SearchItem[] {
const results = data?.status === 200 ? data.data.results : []; if (!data || typeof data !== 'object') {
return [];
}
const handleSubmit = (e: FormEvent) => { if (!('status' in data) || data.status !== 200) {
e.preventDefault(); return [];
// Trigger refetch by updating query }
};
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 ( return (
<div className="flex w-full flex-col gap-4 md:flex-row"> <div className="flex w-full flex-col gap-4 md:flex-row">
<div className="flex grow flex-col gap-4"> <div className="flex grow flex-col gap-4">
{/* Search Form */} <div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<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={onSubmit}>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <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">
<SearchIcon size={15} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<input <input
type="text" type="text"
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => onQueryChange(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="Query" placeholder="Query"
/> />
</div> </div>
</div> </div>
<div className="relative flex min-w-[12em]"> <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} /> <BookIcon size={15} />
</span> </span>
<select <select
value={source} value={source}
onChange={e => setSource(e.target.value as GetSearchSource)} onChange={e => onSourceChange(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" 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={GetSearchSource.LibGen}>Library Genesis</option>
<option value="Annas Archive">Annas Archive</option> <option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
</select> </select>
</div> </div>
<div className="lg:w-60"> <div className="lg:w-60">
@@ -58,38 +86,37 @@ export default function SearchPage() {
</form> </form>
</div> </div>
{/* Search Results Table */}
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <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"> <table className="min-w-full bg-surface text-sm leading-normal text-content md:text-sm">
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-content-muted">
<tr> <tr>
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"></th> <th className="w-12 border-b border-border p-3 text-left font-normal uppercase"></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">
Document Document
</th> </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 Series
</th> </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 Type
</th> </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 Size
</th> </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 Date
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="text-black dark:text-white"> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td className="p-3 text-center" colSpan={6}> <td className="p-3 text-center" colSpan={6}>
Loading... <LoadingState />
</td> </td>
</tr> </tr>
)} )}
{!isLoading && !results && ( {!isLoading && results.length === 0 && (
<tr> <tr>
<td className="p-3 text-center" colSpan={6}> <td className="p-3 text-center" colSpan={6}>
No Results No Results
@@ -97,27 +124,26 @@ export default function SearchPage() {
</tr> </tr>
)} )}
{!isLoading && {!isLoading &&
results && results.map(item => (
results.map((item: SearchItem) => (
<tr key={item.id}> <tr key={item.id}>
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500"> <td className="border-b border-border p-3 text-content-muted">
<button className="hover:text-purple-600" title="Download"> <button className="hover:text-primary-600" title="Download">
<DownloadIcon size={15} /> <DownloadIcon size={15} />
</button> </button>
</td> </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'} {item.author || 'N/A'} - {item.title || 'N/A'}
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-border p-3">
<p>{item.series || 'N/A'}</p> <p>{item.series || 'N/A'}</p>
</td> </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> <p>{item.file_type || 'N/A'}</p>
</td> </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> <p>{item.file_size || 'N/A'}</p>
</td> </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> <p>{item.upload_date || 'N/A'}</p>
</td> </td>
</tr> </tr>
@@ -129,3 +155,41 @@ export default function SearchPage() {
</div> </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}
/>
);
}

View File

@@ -5,12 +5,21 @@ import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors'; 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() { export default function SettingsPage() {
const { data, isLoading } = useGetSettings(); const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings(); const updateSettings = useUpdateSettings();
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null; const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
const { showInfo, showError } = useToasts(); const { showInfo, showError } = useToasts();
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@@ -31,15 +40,21 @@ export default function SettingsPage() {
} }
try { try {
await updateSettings.mutateAsync({ const response = await updateSettings.mutateAsync({
data: { data: {
password: password, password,
new_password: newPassword, new_password: newPassword,
}, },
}); });
if (response.status >= 200 && response.status < 300) {
showInfo('Password updated successfully'); showInfo('Password updated successfully');
setPassword(''); setPassword('');
setNewPassword(''); setNewPassword('');
return;
}
showError('Failed to update password: ' + getErrorMessage(response.data));
} catch (error) { } catch (error) {
showError('Failed to update password: ' + getErrorMessage(error)); showError('Failed to update password: ' + getErrorMessage(error));
} }
@@ -49,12 +64,18 @@ export default function SettingsPage() {
e.preventDefault(); e.preventDefault();
try { try {
await updateSettings.mutateAsync({ const response = await updateSettings.mutateAsync({
data: { data: {
timezone: timezone, timezone,
}, },
}); });
if (response.status >= 200 && response.status < 300) {
showInfo('Timezone updated successfully'); showInfo('Timezone updated successfully');
return;
}
showError('Failed to update timezone: ' + getErrorMessage(response.data));
} catch (error) { } catch (error) {
showError('Failed to update timezone: ' + getErrorMessage(error)); showError('Failed to update timezone: ' + getErrorMessage(error));
} }
@@ -64,35 +85,43 @@ export default function SettingsPage() {
return ( return (
<div className="flex w-full flex-col gap-4 md:flex-row"> <div className="flex w-full flex-col gap-4 md:flex-row">
<div> <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="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-gray-200 dark:bg-gray-600" /> <div className="mb-4 size-16 rounded-full bg-surface-strong" />
<div className="h-6 w-32 rounded bg-gray-200 dark:bg-gray-600" /> <div className="h-6 w-32 rounded bg-surface-strong" />
</div> </div>
</div> </div>
<div className="flex grow flex-col gap-4"> <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="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" /> <div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="flex gap-4"> <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-surface-strong" />
<div className="h-12 flex-1 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-gray-200 dark:bg-gray-600" /> <div className="h-10 w-40 rounded bg-surface-strong" />
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 rounded bg-white p-4 shadow-lg dark:bg-gray-700"> <div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" /> <div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="flex gap-4"> <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-surface-strong" />
<div className="h-10 w-40 rounded bg-gray-200 dark:bg-gray-600" /> <div className="h-10 w-40 rounded bg-surface-strong" />
</div> </div>
</div> </div>
<div className="flex flex-col rounded bg-white p-4 shadow-lg dark:bg-gray-700"> <div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-24 rounded bg-gray-200 dark:bg-gray-600" /> <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="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-surface-strong" />
<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-gray-200 dark:bg-gray-600" /> <div className="h-6 flex-1 rounded bg-surface-strong" />
</div> </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> </div>
</div> </div>
@@ -101,43 +130,41 @@ export default function SettingsPage() {
return ( return (
<div className="flex w-full flex-col gap-4 md:flex-row"> <div className="flex w-full flex-col gap-4 md:flex-row">
{/* User Profile Card */}
<div> <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} /> <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> </div>
<div className="flex grow flex-col gap-4"> <div className="flex grow flex-col gap-4">
{/* Change Password Form */} <div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<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 text-content">Change Password</p>
<p className="mb-2 text-lg font-semibold">Change Password</p>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}> <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
<div className="flex grow flex-col"> <div className="flex grow flex-col">
<div className="relative flex"> <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} /> <PasswordIcon size={15} />
</span> </span>
<input <input
type="password" type="password"
value={password} value={password}
onChange={e => setPassword(e.target.value)} 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" placeholder="Password"
/> />
</div> </div>
</div> </div>
<div className="flex grow flex-col"> <div className="flex grow flex-col">
<div className="relative flex"> <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} /> <PasswordIcon size={15} />
</span> </span>
<input <input
type="password" type="password"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} 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" placeholder="New Password"
/> />
</div> </div>
@@ -150,18 +177,56 @@ export default function SettingsPage() {
</form> </form>
</div> </div>
{/* Change Timezone Form */} <div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<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"> <div className="flex items-center justify-between gap-4">
<p className="mb-2 text-lg font-semibold">Change Timezone</p> <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}> <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
<div className="relative flex grow"> <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} /> <ClockIcon size={15} />
</span> </span>
<select <select
value={timezone || 'UTC'} value={timezone || 'UTC'}
onChange={e => setTimezone(e.target.value)} 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="UTC">UTC</option>
<option value="America/New_York">America/New_York</option> <option value="America/New_York">America/New_York</option>
@@ -183,24 +248,23 @@ export default function SettingsPage() {
</form> </form>
</div> </div>
{/* Devices Table */} <div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<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 text-content">Devices</p>
<p className="text-lg font-semibold">Devices</p> <table className="min-w-full bg-surface text-sm">
<table className="min-w-full bg-white text-sm dark:bg-gray-700"> <thead className="text-content-muted">
<thead className="text-gray-800 dark:text-gray-400">
<tr> <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 Name
</th> </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 Last Sync
</th> </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 Created
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="text-black dark:text-white"> <tbody className="text-content">
{!settingsData?.devices || settingsData.devices.length === 0 ? ( {!settingsData?.devices || settingsData.devices.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={3}> <td className="p-3 text-center" colSpan={3}>

View 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,
};
}

View File

@@ -0,0 +1,7 @@
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
cleanup();
});

View 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
View 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
View 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>;
}

View 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');
});
});

View File

@@ -34,12 +34,6 @@ describe('formatNumber', () => {
expect(formatNumber(-1500000)).toBe('-1.50M'); 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', () => { describe('formatDuration', () => {
@@ -68,9 +62,4 @@ describe('formatDuration', () => {
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s'); 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');
});
}); });

View 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,
});
}

View File

@@ -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} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'], content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'media', darkMode: 'class',
theme: { 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: [], plugins: [],
}; };

View File

@@ -22,4 +22,8 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
}, },
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
}); });