34 Commits

Author SHA1 Message Date
evan 64aa000323 build(dev): use air for backend live reload
continuous-integration/drone/pr Build is failing
2026-05-02 15:39:28 -04:00
evan a865457bbf build(dev): split frontend and backend dev targets 2026-05-02 15:36:04 -04:00
evan a950d50440 feat(dev): add local auth bypass mode
continuous-integration/drone/pr Build is failing
2026-05-02 15:32:48 -04:00
evan 00faf9cea8 feat(pagination): paginate activity and progress lists 2026-05-02 15:32:10 -04:00
evan 75c872264f chore: remove unnecessary crap ai added
continuous-integration/drone/pr Build is failing
2026-04-03 19:46:05 -04:00
evan 0930054847 more reader
continuous-integration/drone/pr Build is failing
2026-04-03 13:45:17 -04:00
evan aa812c6917 wip reader migration 2026-04-03 12:15:48 -04:00
evan 8ec3349b7c chore(api): update to allow CRUD progress and activity in v1 2026-04-03 10:37:50 -04:00
evan decc3f0195 fix: toast theme & error msgs 2026-04-03 10:08:13 -04:00
evan b13f9b362c theme draft 2 (done?) 2026-03-22 17:21:34 -04:00
evan 6c2c4f6b8b remove dumb auth 2026-03-22 17:21:34 -04:00
evan d38392ac9a theme draft 1 2026-03-22 17:21:34 -04:00
evan 63ad73755d wip 22 2026-03-22 17:21:34 -04:00
evan 784e53c557 wip 21 2026-03-22 17:21:34 -04:00
evan 9ed63b2695 wip 20 2026-03-22 17:21:34 -04:00
evan 27e651c4f5 wip 19 2026-03-22 17:21:34 -04:00
evan 7e96e41ba4 wip 18 2026-03-22 17:21:33 -04:00
evan ee1d62858b wip 17 2026-03-22 17:21:33 -04:00
evan 4d133994ab wip 16 2026-03-22 17:21:33 -04:00
evan ba919bbde4 wip 15 2026-03-22 17:21:33 -04:00
evan 197a1577c2 wip 14 2026-03-22 17:21:33 -04:00
evan fd9afe86b0 wip 13 2026-03-22 17:21:33 -04:00
evan 93707ff513 wip 12 2026-03-22 17:21:33 -04:00
evan 75e0228fe0 wip 11 2026-03-22 17:21:33 -04:00
evan b1b8eb297e wip 10 2026-03-22 17:21:33 -04:00
evan 7c47f2d2eb wip 9 2026-03-22 17:21:33 -04:00
evan c46dcb440d wip 8 2026-03-22 17:21:33 -04:00
evan 5cb17bace7 wip 7 2026-03-22 17:21:32 -04:00
evan ecf77fd105 wip 6 2026-03-22 17:21:32 -04:00
evan e289d1a29b wip 5 2026-03-22 17:21:32 -04:00
evan 3e9a193d08 wip 4 2026-03-22 17:21:32 -04:00
evan 4306d86080 wip 3 2026-03-22 17:21:32 -04:00
evan d40f8fc375 wip 2 2026-03-22 17:21:32 -04:00
evan c84bc2522e wip 1 2026-03-22 17:21:32 -04:00
73 changed files with 3665 additions and 5991 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
root = "." root = "."
tmp_dir = "tmp" tmp_dir = "_scratch/air"
[build] [build]
cmd = "go build -o ./tmp/antholume ." cmd = "go build -o ./_scratch/air/antholume ."
full_bin = "./tmp/antholume serve" full_bin = "./_scratch/air/antholume serve"
delay = 1000 delay = 1000
include_ext = ["go", "tpl", "tmpl", "html", "css", "js", "yaml", "yml"] include_ext = ["go", "tpl", "tmpl", "html", "css", "js", "yaml", "yml"]
exclude_dir = ["_scratch", "build", "data", "frontend", "node_modules"] exclude_dir = ["_scratch", "build", "data", "frontend", "node_modules"]
+4 -18
View File
@@ -3,10 +3,8 @@ type: docker
name: default name: default
trigger: trigger:
ref: branch:
- refs/heads/master - master
- refs/tags/**
- refs/pull/**
steps: steps:
# Unit Tests # Unit Tests
@@ -21,26 +19,14 @@ steps:
commands: commands:
- git fetch --tags - git fetch --tags
# Compute docker tag: git tag for tag builds, pr-<num> for PRs, dev for master
- name: compute tags
image: alpine
commands:
- |
if [ "$DRONE_BUILD_EVENT" = "tag" ]; then
echo "$DRONE_TAG" > .tags
elif [ "$DRONE_BUILD_EVENT" = "pull_request" ]; then
echo "pr-$DRONE_PULL_REQUEST" > .tags
else
echo "dev" > .tags
fi
- cat .tags
# Publish docker image # Publish docker image
- name: publish docker - name: publish docker
image: plugins/docker image: plugins/docker
settings: settings:
repo: gitea.va.reichard.io/evan/antholume repo: gitea.va.reichard.io/evan/antholume
registry: gitea.va.reichard.io registry: gitea.va.reichard.io
tags:
- dev
custom_dns: custom_dns:
- 8.8.8.8 - 8.8.8.8
username: username:
+3 -3
View File
@@ -22,7 +22,7 @@ Edit:
Regenerate: Regenerate:
- `go generate ./api/v1/generate.go` - `go generate ./api/v1/generate.go`
- `cd frontend && pnpm run generate:api` - `cd frontend && bun run generate:api`
Notes: 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. - 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.
@@ -51,7 +51,7 @@ Regenerate:
### Notes ### Notes
- The Go server embeds `templates/*` and `assets/*`. - The Go server embeds `templates/*` and `assets/*`.
- Root Tailwind output is built to `assets/style.css`. Two separate Tailwind setups exist: the legacy server-rendered app uses **Tailwind v3** via the root `tailwind.config.js` + `make legacy_tailwind`; the React app in `frontend/` uses **Tailwind v4** (CSS-first, no config file). Keep them distinct. - 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. - 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. - SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts.
- `DISABLE_AUTH=true` bypasses authentication on **all** routes (v1 API, legacy web app, KOSync, OPDS). Set `DISABLE_AUTH_USER=<username>` to control which database user the session impersonates (defaults to the first user in the DB). The user must already exist. - `DISABLE_AUTH=true` bypasses authentication on **all** routes (v1 API, legacy web app, KOSync, OPDS). Set `DISABLE_AUTH_USER=<username>` to control which database user the session impersonates (defaults to the first user in the DB). The user must already exist.
@@ -64,7 +64,7 @@ For frontend-specific implementation notes and commands, also read:
## 6) Regeneration Summary ## 6) Regeneration Summary
- Go API: `go generate ./api/v1/generate.go` - Go API: `go generate ./api/v1/generate.go`
- Frontend API client: `cd frontend && pnpm run generate:api` - Frontend API client: `cd frontend && bun run generate:api`
- SQLC: `sqlc generate` - SQLC: `sqlc generate`
## 7) Live Dev Server Debugging ## 7) Live Dev Server Debugging
+2 -7
View File
@@ -1,4 +1,4 @@
.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend dev_noauth clean tests .PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend clean tests
DEV_ENV = GIN_MODE=release \ DEV_ENV = GIN_MODE=release \
CONFIG_PATH=./data \ CONFIG_PATH=./data \
@@ -9,8 +9,6 @@ DEV_ENV = GIN_MODE=release \
COOKIE_AUTH_KEY=1234 \ COOKIE_AUTH_KEY=1234 \
LOG_LEVEL=debug LOG_LEVEL=debug
DEV_USER ?= evan
build_local: legacy_tailwind build_local: legacy_tailwind
go mod download go mod download
rm -r ./build || true rm -r ./build || true
@@ -51,10 +49,7 @@ dev_backend:
$(DEV_ENV) air $(DEV_ENV) air
dev_frontend: dev_frontend:
cd frontend && pnpm run dev cd frontend && bun run dev
dev_noauth:
DISABLE_AUTH=true DISABLE_AUTH_USER=$(DEV_USER) $(MAKE) dev
clean: clean:
rm -rf ./build rm -rf ./build
+3 -6
View File
@@ -124,13 +124,10 @@ func (api *API) appGetDocuments(c *gin.Context) {
return return
} }
length, err := api.db.Queries.GetDocumentsWithStatsCount(c, database.GetDocumentsWithStatsCountParams{ length, err := api.db.Queries.GetDocumentsSize(c, query)
Deleted: ptr.Of(false),
Query: query,
})
if err != nil { if err != nil {
log.Error("GetDocumentsWithStatsCount DB Error: ", err) log.Error("GetDocumentsSize DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStatsCount DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
return return
} }
+7 -314
View File
@@ -4,13 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
"testing" "testing"
"time"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"reichard.io/antholume/config" "reichard.io/antholume/config"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/utils"
) )
type DocumentsTestSuite struct { type DocumentsTestSuite struct {
@@ -39,54 +36,12 @@ func (suite *DocumentsTestSuite) SetupTest() {
suite.NoError(err) suite.NoError(err)
} }
func (suite *DocumentsTestSuite) seedDocumentStats() { // DOCUMENT - TODO:
suite.createTestUserAndDevice() // - 󰊕 (q *Queries) GetDocumentProgress
// - 󰊕 (q *Queries) GetDocumentWithStats
var err error // - 󰊕 (q *Queries) GetDocumentsSize
_, err = suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{ // - 󰊕 (q *Queries) GetDocumentsWithStats
DocumentID: documentID, // - 󰊕 (q *Queries) GetMissingDocuments
DeviceID: deviceID,
UserID: userID,
StartTime: time.Now().UTC().Format(time.RFC3339),
Duration: 60,
StartPercentage: 0.10,
EndPercentage: 0.20,
})
suite.Require().NoError(err)
_, err = suite.dbm.Queries.UpdateProgress(context.Background(), UpdateProgressParams{
UserID: userID,
DocumentID: documentID,
DeviceID: deviceID,
Percentage: 0.42,
Progress: "/6/2[test]",
})
suite.Require().NoError(err)
err = suite.dbm.CacheTempTables(context.Background())
suite.Require().NoError(err)
}
func (suite *DocumentsTestSuite) createTestUserAndDevice() {
rawAuthHash, err := utils.GenerateToken(64)
suite.Require().NoError(err)
authHash := fmt.Sprintf("%x", rawAuthHash)
_, err = suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: userID,
Pass: &userPass,
AuthHash: &authHash,
})
suite.Require().NoError(err)
_, err = suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: deviceID,
UserID: userID,
DeviceName: deviceName,
})
suite.Require().NoError(err)
}
func (suite *DocumentsTestSuite) TestGetDocument() { func (suite *DocumentsTestSuite) TestGetDocument() {
doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID) doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
@@ -108,265 +63,6 @@ func (suite *DocumentsTestSuite) TestUpsertDocument() {
suite.Equal(documentAuthor, *doc.Author, "should have document author") suite.Equal(documentAuthor, *doc.Author, "should have document author")
} }
func (suite *DocumentsTestSuite) TestGetDocumentProgress() {
suite.seedDocumentStats()
progress, err := suite.dbm.Queries.GetDocumentProgress(context.Background(), GetDocumentProgressParams{
UserID: userID,
DocumentID: documentID,
})
suite.NoError(err)
suite.Equal(userID, progress.UserID)
suite.Equal(documentID, progress.DocumentID)
suite.Equal(deviceID, progress.DeviceID)
suite.Equal(deviceName, progress.DeviceName)
suite.Equal(0.42, progress.Percentage)
suite.Equal("/6/2[test]", progress.Progress)
}
func (suite *DocumentsTestSuite) TestGetDocumentWithStats() {
suite.seedDocumentStats()
doc, err := suite.dbm.GetDocument(context.Background(), documentID, userID)
suite.NoError(err)
suite.Equal(documentID, doc.ID)
suite.Equal(documentTitle, *doc.Title)
suite.Equal(documentAuthor, *doc.Author)
suite.Equal(documentWords, *doc.Words)
suite.Equal(float64(42), doc.Percentage)
suite.Equal(int64(60), doc.TotalTimeSeconds)
suite.Equal(int64(500), doc.Wpm)
suite.Equal(int64(6), doc.SecondsPerPercent)
}
func (suite *DocumentsTestSuite) TestGetDocumentsSize() {
count, err := suite.dbm.Queries.GetDocumentsSize(context.Background(), nil)
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsSize(context.Background(), "%testTitle%")
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsSize(context.Background(), "%missing%")
suite.NoError(err)
suite.Equal(int64(0), count)
}
func (suite *DocumentsTestSuite) TestGetDocumentsWithStatsCount() {
count, err := suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{})
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
Query: ptr.Of("%testTitle%"),
})
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
Query: ptr.Of("%testAuthor%"),
})
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
Query: ptr.Of("%missing%"),
})
suite.NoError(err)
suite.Equal(int64(0), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
ID: ptr.Of(documentID),
})
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
ID: ptr.Of("missing-id"),
})
suite.NoError(err)
suite.Equal(int64(0), count)
_, err = suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Require().NoError(err)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
Deleted: ptr.Of(false),
})
suite.NoError(err)
suite.Equal(int64(0), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
Deleted: ptr.Of(true),
})
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{})
suite.NoError(err)
suite.Equal(int64(1), count)
count, err = suite.dbm.Queries.GetDocumentsWithStatsCount(context.Background(), GetDocumentsWithStatsCountParams{
ID: ptr.Of(documentID),
Deleted: ptr.Of(true),
})
suite.NoError(err)
suite.Equal(int64(1), count)
}
func (suite *DocumentsTestSuite) TestGetDocumentsWithStats() {
suite.seedDocumentStats()
rows, err := suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Deleted: ptr.Of(false),
Query: ptr.Of("%testTitle%"),
Offset: 0,
Limit: 10,
})
suite.NoError(err)
suite.Len(rows, 1)
suite.Equal(documentID, rows[0].ID)
suite.Equal(documentTitle, *rows[0].Title)
suite.Equal(float64(42), rows[0].Percentage)
suite.Equal(int64(60), rows[0].TotalTimeSeconds)
_, err = suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.NoError(err)
rows, err = suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Deleted: ptr.Of(false),
Offset: 0,
Limit: 10,
})
suite.NoError(err)
suite.Len(rows, 0)
}
func (suite *DocumentsTestSuite) TestGetDocumentsWithStatsFilters() {
suite.createTestUserAndDevice()
otherDocID := "testDocument2"
otherTitle := "otherTitle"
otherAuthor := "otherAuthor"
otherWords := int64(3000)
_, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: otherDocID,
Title: &otherTitle,
Author: &otherAuthor,
Words: &otherWords,
})
suite.Require().NoError(err)
_, err = suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID,
DeviceID: deviceID,
UserID: userID,
StartTime: time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339),
Duration: 60,
StartPercentage: 0.10,
EndPercentage: 0.20,
})
suite.Require().NoError(err)
_, err = suite.dbm.Queries.UpdateProgress(context.Background(), UpdateProgressParams{
UserID: userID,
DocumentID: documentID,
DeviceID: deviceID,
Percentage: 0.42,
Progress: "/6/2[test]",
})
suite.Require().NoError(err)
_, err = suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: otherDocID,
DeviceID: deviceID,
UserID: userID,
StartTime: time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339),
Duration: 30,
StartPercentage: 0.20,
EndPercentage: 0.30,
})
suite.Require().NoError(err)
err = suite.dbm.CacheTempTables(context.Background())
suite.Require().NoError(err)
rows, err := suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
ID: ptr.Of(documentID),
Offset: 0,
Limit: 10,
})
suite.NoError(err)
suite.Len(rows, 1)
suite.Equal(documentID, rows[0].ID)
rows, err = suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Query: ptr.Of("%otherAuthor%"),
Offset: 0,
Limit: 10,
})
suite.NoError(err)
suite.Len(rows, 1)
suite.Equal(otherDocID, rows[0].ID)
rows, err = suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Query: ptr.Of("%does-not-match%"),
Offset: 0,
Limit: 10,
})
suite.NoError(err)
suite.Len(rows, 0)
_, err = suite.dbm.Queries.DeleteDocument(context.Background(), otherDocID)
suite.Require().NoError(err)
rows, err = suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Deleted: ptr.Of(true),
Offset: 0,
Limit: 10,
})
suite.NoError(err)
suite.Len(rows, 1)
suite.Equal(otherDocID, rows[0].ID)
rows, err = suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Offset: 0,
Limit: 10,
})
suite.NoError(err)
suite.Len(rows, 2)
rows, err = suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Offset: 0,
Limit: 1,
})
suite.NoError(err)
suite.Len(rows, 1)
suite.Equal(otherDocID, rows[0].ID)
rows, err = suite.dbm.Queries.GetDocumentsWithStats(context.Background(), GetDocumentsWithStatsParams{
UserID: userID,
Offset: 1,
Limit: 1,
})
suite.NoError(err)
suite.Len(rows, 1)
suite.Equal(documentID, rows[0].ID)
}
func (suite *DocumentsTestSuite) TestDeleteDocument() { func (suite *DocumentsTestSuite) TestDeleteDocument() {
changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID) changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
@@ -389,10 +85,7 @@ func (suite *DocumentsTestSuite) TestGetDeletedDocuments() {
// TODO - Convert GetWantedDocuments -> (sqlc.slice('document_ids')); // TODO - Convert GetWantedDocuments -> (sqlc.slice('document_ids'));
func (suite *DocumentsTestSuite) TestGetWantedDocuments() { func (suite *DocumentsTestSuite) TestGetWantedDocuments() {
wantedDocs, err := suite.dbm.Queries.GetWantedDocuments(context.Background(), GetWantedDocumentsParams{ wantedDocs, err := suite.dbm.Queries.GetWantedDocuments(context.Background(), fmt.Sprintf("[\"%s\"]", documentID))
JsonEach: fmt.Sprintf("[\"%s\"]", documentID),
DocumentIds: fmt.Sprintf("[\"%s\"]", documentID),
})
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(wantedDocs, 1, "should have one wanted document") suite.Len(wantedDocs, 1, "should have one wanted document")
} }
+3 -4
View File
@@ -202,7 +202,6 @@ SELECT
END AS REAL), 2) AS percentage, END AS REAL), 2) AS percentage,
CAST(CASE CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
WHEN dus.read_percentage IS NULL OR dus.read_percentage <= 0 THEN 0.0
ELSE ELSE
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0) / (dus.read_percentage * 100.0)
@@ -216,10 +215,10 @@ WHERE
(docs.id = sqlc.narg('id') OR $id IS NULL) (docs.id = sqlc.narg('id') OR $id IS NULL)
AND (docs.deleted = sqlc.narg(deleted) OR $deleted IS NULL) AND (docs.deleted = sqlc.narg(deleted) OR $deleted IS NULL)
AND ( AND (
$query IS NULL OR ( (
docs.title LIKE sqlc.narg('query') OR docs.title LIKE sqlc.narg('query') OR
docs.author LIKE sqlc.narg('query') docs.author LIKE $query
) ) OR $query IS NULL
) )
ORDER BY dus.last_read DESC, docs.created_at DESC ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT $limit LIMIT $limit
+3 -4
View File
@@ -666,7 +666,6 @@ SELECT
END AS REAL), 2) AS percentage, END AS REAL), 2) AS percentage,
CAST(CASE CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
WHEN dus.read_percentage IS NULL OR dus.read_percentage <= 0 THEN 0.0
ELSE ELSE
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0) / (dus.read_percentage * 100.0)
@@ -680,10 +679,10 @@ WHERE
(docs.id = ?2 OR ?2 IS NULL) (docs.id = ?2 OR ?2 IS NULL)
AND (docs.deleted = ?3 OR ?3 IS NULL) AND (docs.deleted = ?3 OR ?3 IS NULL)
AND ( AND (
?4 IS NULL OR ( (
docs.title LIKE ?4 OR docs.title LIKE ?4 OR
docs.author LIKE ?4 docs.author LIKE ?4
) ) OR ?4 IS NULL
) )
ORDER BY dus.last_read DESC, docs.created_at DESC ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT ?6 LIMIT ?6
@@ -694,7 +693,7 @@ type GetDocumentsWithStatsParams struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
ID *string `json:"id"` ID *string `json:"id"`
Deleted *bool `json:"-"` Deleted *bool `json:"-"`
Query interface{} `json:"query"` Query *string `json:"query"`
Offset int64 `json:"offset"` Offset int64 `json:"offset"`
Limit int64 `json:"limit"` Limit int64 `json:"limit"`
} }
+1 -1
View File
@@ -25,8 +25,8 @@
golangci-lint golangci-lint
gopls gopls
bun
nodejs nodejs
pnpm
tailwindcss tailwindcss
typescript-language-server typescript-language-server
]; ];
-31
View File
@@ -1,31 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"jsPlugins": ["oxlint-tailwindcss"],
"ignorePatterns": ["src/generated/**", "dist/**"],
"plugins": ["react", "typescript", "oxc"],
"settings": {
"tailwindcss": {
"entryPoint": "src/index.css"
}
},
"rules": {
"no-console": ["warn", { "allow": ["warn", "error"] }],
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
],
"typescript/no-explicit-any": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"tailwindcss/no-duplicate-classes": "error",
"tailwindcss/no-conflicting-classes": "error",
"tailwindcss/no-deprecated-classes": "error",
"tailwindcss/no-unnecessary-whitespace": "warn",
"tailwindcss/no-unknown-classes": "off"
}
}
+17 -27
View File
@@ -5,12 +5,11 @@ Also follow the repository root guide at `../AGENTS.md`.
## 1) Stack ## 1) Stack
- Package manager: `pnpm` - Package manager: `bun`
- Framework: React + Vite - Framework: React + Vite
- Data fetching: React Query - Data fetching: React Query
- API generation: Orval - API generation: Orval
- Styling: Tailwind CSS v4 (CSS-first config) - Linting: ESLint + Tailwind plugin
- Linting: oxlint (config in `.oxlintrc.json`) + `oxlint-tailwindcss` for Tailwind rules
- Formatting: Prettier - Formatting: Prettier
## 2) Conventions ## 2) Conventions
@@ -18,24 +17,17 @@ Also follow the repository root guide at `../AGENTS.md`.
- Use local icon components from `src/icons/`. - Use local icon components from `src/icons/`.
- Do not add external icon libraries. - Do not add external icon libraries.
- Prefer generated types from `src/generated/model/` over `any`. - Prefer generated types from `src/generated/model/` over `any`.
- Unwrap API responses by narrowing on `status` (e.g. `data?.status === 200 ? data.data : undefined`); the generated response types are discriminated unions, so this needs no `as XResponse` cast.
- Type form submit handlers as `SyntheticEvent` (optionally `SyntheticEvent<HTMLFormElement>`); `@types/react@19` deprecates `FormEvent`.
- Route mutation success/error feedback through `useMutationWithToast` (`src/hooks/`); error-toast-on-failure is the app-wide pattern (treat non-2xx as failure).
- Shared input styling lives in `TextInput` / the exported `inputClassName` (`src/components/TextInput.tsx`); reuse rather than re-pasting the input class string.
- Render tabular data with the shared `<Table>` (`src/components/Table.tsx`). Columns are `{ id, header: ReactNode, render(row, index), className? }``id` is decoupled from data, so action columns are first-class. The table owns its own loading skeleton and empty state; don't hand-roll `<table>` markup for data grids. (Genuinely different widget shapes — e.g. a file-browser or a card-grid — are fine to build bespoke.)
- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`.
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them. - 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. - 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 (the single loading convention); avoid early returns that unmount search/filter forms during fetches. - Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
- Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first. - 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.
- Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`.
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape. - Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
## 3) Generated API client ## 3) Generated API client
- Do not edit `src/generated/**` directly. - Do not edit `src/generated/**` directly.
- Edit `../api/v1/openapi.yaml` and regenerate instead. - Edit `../api/v1/openapi.yaml` and regenerate instead.
- Regenerate with: `pnpm run generate:api` - Regenerate with: `bun run generate:api`
### Important behavior ### Important behavior
@@ -51,27 +43,25 @@ Also follow the repository root guide at `../AGENTS.md`.
## 5) Commands ## 5) Commands
- Lint: `pnpm run lint` - Lint: `bun run lint`
- Typecheck: `pnpm run typecheck` - Typecheck: `bun run typecheck`
- Lint fix: `pnpm run lint:fix` - Lint fix: `bun run lint:fix`
- Format check: `pnpm run format` - Format check: `bun run format`
- Format fix: `pnpm run format:fix` - Format fix: `bun run format:fix`
- Build: `pnpm run build` - Build: `bun run build`
- Generate API client: `pnpm run generate:api` - Generate API client: `bun run generate:api`
## 6) Validation Notes ## 6) Validation Notes
- oxlint ignores `src/generated/**` and `dist/**` (via `ignorePatterns` in `.oxlintrc.json`). - ESLint ignores `src/generated/**`.
- `lint` runs `oxlint --max-warnings=0`; keep the tree warning-free. `react-hooks/exhaustive-deps` is enforced — fix deps rather than disabling the rule; use a justified inline `// oxlint-disable-next-line` only for genuine init-once effects.
- The Tailwind lint plugin uses oxlint `jsPlugins` (experimental, not run by the editor language server), so Tailwind diagnostics surface via CLI/CI, not in-editor. It reads the theme from `src/index.css` (`settings.tailwindcss.entryPoint`).
- Frontend unit tests use Vitest and live alongside source as `src/**/*.test.ts(x)`. - Frontend unit tests use Vitest and live alongside source as `src/**/*.test.ts(x)`.
- Read `TESTING_STRATEGY.md` before adding or expanding frontend tests. - Read `TESTING_STRATEGY.md` before adding or expanding frontend tests.
- Prefer tests for meaningful app behavior, branching logic, side effects, and user-visible outcomes. - 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. - Avoid low-value tests that mainly assert exact styling classes, duplicate existing coverage, or re-test framework/library behavior.
- `pnpm run lint` includes test files but does not typecheck. - `bun run lint` includes test files but does not typecheck.
- Use `pnpm run typecheck` to run TypeScript validation for app code and colocated tests without a full production build. - Use `bun run typecheck` to run TypeScript validation for app code and colocated tests without a full production build.
- Run frontend tests with `pnpm run test`. - Run frontend tests with `bun run test`.
- `pnpm run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build. - `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. - When possible, validate changed files directly before escalating to full-project fixes.
## 7) Live Dev Server Debugging ## 7) Live Dev Server Debugging
+5 -5
View File
@@ -68,7 +68,7 @@ The frontend mirrors the existing SSR templates structure:
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec: The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
```bash ```bash
pnpm run generate:api npm run generate:api
``` ```
This generates: This generates:
@@ -80,16 +80,16 @@ This generates:
```bash ```bash
# Install dependencies # Install dependencies
pnpm install npm install
# Generate API types (if OpenAPI spec changes) # Generate API types (if OpenAPI spec changes)
pnpm run generate:api npm run generate:api
# Start development server # Start development server
pnpm run dev npm run dev
# Build for production # Build for production
pnpm run build npm run build
``` ```
## Deployment ## Deployment
+3 -3
View File
@@ -68,6 +68,6 @@ Trim or remove tests that are brittle, duplicated, or mostly validate tooling ra
For frontend test work, validate with: For frontend test work, validate with:
- `cd frontend && pnpm run lint` - `cd frontend && bun run lint`
- `cd frontend && pnpm run typecheck` - `cd frontend && bun run typecheck`
- `cd frontend && pnpm run test` - `cd frontend && bun run test`
+1350
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
import js from "@eslint/js";
import typescriptParser from "@typescript-eslint/parser";
import typescriptPlugin from "@typescript-eslint/eslint-plugin";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import tailwindcss from "eslint-plugin-tailwindcss";
import prettier from "eslint-plugin-prettier";
import eslintConfigPrettier from "eslint-config-prettier";
export default [
js.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
ignores: ["**/generated/**"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
projectService: true,
},
globals: {
localStorage: "readonly",
sessionStorage: "readonly",
document: "readonly",
window: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
HTMLElement: "readonly",
HTMLDivElement: "readonly",
HTMLButtonElement: "readonly",
HTMLAnchorElement: "readonly",
MouseEvent: "readonly",
Node: "readonly",
File: "readonly",
Blob: "readonly",
FormData: "readonly",
alert: "readonly",
confirm: "readonly",
prompt: "readonly",
React: "readonly",
},
},
plugins: {
"@typescript-eslint": typescriptPlugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
tailwindcss,
prettier,
},
rules: {
...eslintConfigPrettier.rules,
...tailwindcss.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"no-useless-catch": "off",
},
settings: {
react: {
version: "detect",
},
},
},
];
+16 -11
View File
@@ -9,14 +9,16 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"generate:api": "orval", "generate:api": "orval",
"lint": "oxlint --max-warnings=0", "lint": "eslint src --max-warnings=0",
"lint:fix": "oxlint --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" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.16", "@tanstack/react-query": "^5.62.16",
"ajv": "^8.18.0",
"axios": "^1.13.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"epubjs": "^0.3.93", "epubjs": "^0.3.93",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
@@ -27,25 +29,28 @@
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.2", "@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@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/parser": "^8.13.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"oxlint": "^1.72.0", "postcss": "^8.4.49",
"oxlint-tailwindcss": "^1.3.4",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"tailwindcss": "^4.3.2", "tailwindcss": "^3.4.17",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5", "vite": "^6.0.5",
"vitest": "^4.1.0" "vitest": "^4.1.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
} }
} }
-4233
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
allowBuilds:
core-js: set this to true or false
es5-ext: set this to true or false
esbuild: set this to true or false
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+94 -19
View File
@@ -1,4 +1,4 @@
import { Outlet, Route, Routes as ReactRoutes } from 'react-router-dom'; import { Route, Routes as ReactRoutes } from 'react-router-dom';
import Layout from './components/Layout'; import Layout from './components/Layout';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import DocumentsPage from './pages/DocumentsPage'; import DocumentsPage from './pages/DocumentsPage';
@@ -16,33 +16,108 @@ import AdminUsersPage from './pages/AdminUsersPage';
import AdminLogsPage from './pages/AdminLogsPage'; import AdminLogsPage from './pages/AdminLogsPage';
import ReaderPage from './pages/ReaderPage'; import ReaderPage from './pages/ReaderPage';
import { ProtectedRoute } from './auth/ProtectedRoute'; import { ProtectedRoute } from './auth/ProtectedRoute';
import { AdminRoute } from './auth/AdminRoute';
export function Routes() { export function Routes() {
return ( return (
<ReactRoutes> <ReactRoutes>
<Route path="/" element={<Layout />}>
<Route <Route
path="/" index
element={ element={
<ProtectedRoute> <ProtectedRoute>
<Layout /> <HomePage />
</ProtectedRoute> </ProtectedRoute>
} }
> />
<Route index element={<HomePage />} /> <Route
<Route path="documents" element={<DocumentsPage />} /> path="documents"
<Route path="documents/:id" element={<DocumentPage />} /> element={
<Route path="progress" element={<ProgressPage />} /> <ProtectedRoute>
<Route path="activity" element={<ActivityPage />} /> <DocumentsPage />
<Route path="search" element={<SearchPage />} /> </ProtectedRoute>
<Route path="settings" element={<SettingsPage />} /> }
<Route path="admin" element={<AdminRoute><Outlet /></AdminRoute>}> />
<Route index element={<AdminPage />} /> <Route
<Route path="import" element={<AdminImportPage />} /> path="documents/:id"
<Route path="import-results" element={<AdminImportResultsPage />} /> element={
<Route path="users" element={<AdminUsersPage />} /> <ProtectedRoute>
<Route path="logs" element={<AdminLogsPage />} /> <DocumentPage />
</Route> </ProtectedRoute>
}
/>
<Route
path="progress"
element={
<ProtectedRoute>
<ProgressPage />
</ProtectedRoute>
}
/>
<Route
path="activity"
element={
<ProtectedRoute>
<ActivityPage />
</ProtectedRoute>
}
/>
<Route
path="search"
element={
<ProtectedRoute>
<SearchPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="admin/import"
element={
<ProtectedRoute>
<AdminImportPage />
</ProtectedRoute>
}
/>
<Route
path="admin/import-results"
element={
<ProtectedRoute>
<AdminImportResultsPage />
</ProtectedRoute>
}
/>
<Route
path="admin/users"
element={
<ProtectedRoute>
<AdminUsersPage />
</ProtectedRoute>
}
/>
<Route
path="admin/logs"
element={
<ProtectedRoute>
<AdminLogsPage />
</ProtectedRoute>
}
/>
</Route> </Route>
<Route <Route
path="/reader/:id" path="/reader/:id"
+4 -4
View File
@@ -13,7 +13,7 @@ import {
getAuthenticatedAuthState, getAuthenticatedAuthState,
getUnauthenticatedAuthState, getUnauthenticatedAuthState,
resolveAuthStateFromMe, resolveAuthStateFromMe,
authUserFromMutation, validateAuthMutationResponse,
} from './authHelpers'; } from './authHelpers';
interface AuthContextType extends AuthState { interface AuthContextType extends AuthState {
@@ -63,7 +63,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, },
}); });
const user = authUserFromMutation(response); const user = validateAuthMutationResponse(response, 200);
if (!user) { if (!user) {
setAuthState(getUnauthenticatedAuthState()); setAuthState(getUnauthenticatedAuthState());
throw new Error('Login failed'); throw new Error('Login failed');
@@ -91,7 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, },
}); });
const user = authUserFromMutation(response); const user = validateAuthMutationResponse(response, 201);
if (!user) { if (!user) {
setAuthState(getUnauthenticatedAuthState()); setAuthState(getUnauthenticatedAuthState());
throw new Error('Registration failed'); throw new Error('Registration failed');
@@ -113,7 +113,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logoutMutation.mutate(undefined, { logoutMutation.mutate(undefined, {
onSuccess: async () => { onSuccess: async () => {
setAuthState(getUnauthenticatedAuthState()); setAuthState(getUnauthenticatedAuthState());
queryClient.removeQueries({ queryKey: getGetMeQueryKey() }); await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
navigate('/login'); navigate('/login');
}, },
}); });
+39 -21
View File
@@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest';
import { import {
getCheckingAuthState, getCheckingAuthState,
getUnauthenticatedAuthState, getUnauthenticatedAuthState,
normalizeAuthenticatedUser,
resolveAuthStateFromMe, resolveAuthStateFromMe,
authUserFromMutation, validateAuthMutationResponse,
type AuthState, type AuthState,
} from './authHelpers'; } from './authHelpers';
@@ -14,6 +15,20 @@ const previousState: AuthState = {
}; };
describe('authHelpers', () => { 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', () => { it('returns a checking state while preserving previous auth information', () => {
expect( expect(
getCheckingAuthState({ getCheckingAuthState({
@@ -34,7 +49,6 @@ describe('authHelpers', () => {
meData: { meData: {
status: 200, status: 200,
data: { username: 'evan', is_admin: false }, data: { username: 'evan', is_admin: false },
headers: new Headers(),
}, },
meError: undefined, meError: undefined,
meLoading: false, meLoading: false,
@@ -52,8 +66,6 @@ describe('authHelpers', () => {
resolveAuthStateFromMe({ resolveAuthStateFromMe({
meData: { meData: {
status: 401, status: 401,
data: { code: 401, message: 'unauthorized' },
headers: new Headers(),
}, },
meError: undefined, meError: undefined,
meLoading: false, meLoading: false,
@@ -93,7 +105,9 @@ describe('authHelpers', () => {
it('returns the previous state with checking disabled when there is no decisive me result', () => { it('returns the previous state with checking disabled when there is no decisive me result', () => {
expect( expect(
resolveAuthStateFromMe({ resolveAuthStateFromMe({
meData: undefined, meData: {
status: 204,
},
meError: undefined, meError: undefined,
meLoading: false, meLoading: false,
previousState: { previousState: {
@@ -109,31 +123,35 @@ describe('authHelpers', () => {
}); });
}); });
it('extracts the user from successful login and register responses', () => { it('validates auth mutation responses by expected status and payload shape', () => {
expect( expect(
authUserFromMutation({ validateAuthMutationResponse(
{
status: 200, status: 200,
data: { username: 'evan', is_admin: false }, data: { username: 'evan', is_admin: false },
headers: new Headers(), },
}) 200
)
).toEqual({ username: 'evan', is_admin: false }); ).toEqual({ username: 'evan', is_admin: false });
expect( expect(
authUserFromMutation({ validateAuthMutationResponse(
{
status: 201, status: 201,
data: { username: 'evan', is_admin: true }, data: { username: 'evan', is_admin: false },
headers: new Headers(), },
}) 200
).toEqual({ username: 'evan', is_admin: true }); )
}); ).toBeNull();
it('returns null for unsuccessful auth mutation responses', () => {
expect( expect(
authUserFromMutation({ validateAuthMutationResponse(
status: 401, {
data: { code: 401, message: 'unauthorized' }, status: 200,
headers: new Headers(), data: { username: 'evan' },
}) },
200
)
).toBeNull(); ).toBeNull();
}); });
}); });
+41 -13
View File
@@ -1,11 +1,7 @@
import type { export interface AuthUser {
getMeResponse, username: string;
loginResponse, is_admin: boolean;
registerResponse, }
} from '../generated/anthoLumeAPIV1';
import type { LoginResponse } from '../generated/model';
export type AuthUser = LoginResponse;
export interface AuthState { export interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -13,6 +9,11 @@ export interface AuthState {
isCheckingAuth: boolean; isCheckingAuth: boolean;
} }
interface ResponseLike {
status?: number;
data?: unknown;
}
export function getUnauthenticatedAuthState(): AuthState { export function getUnauthenticatedAuthState(): AuthState {
return { return {
isAuthenticated: false, isAuthenticated: false,
@@ -37,8 +38,27 @@ export function getAuthenticatedAuthState(user: AuthUser): AuthState {
}; };
} }
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: { export function resolveAuthStateFromMe(params: {
meData?: getMeResponse; meData?: ResponseLike;
meError?: unknown; meError?: unknown;
meLoading: boolean; meLoading: boolean;
previousState: AuthState; previousState: AuthState;
@@ -50,7 +70,10 @@ export function resolveAuthStateFromMe(params: {
} }
if (meData?.status === 200) { if (meData?.status === 200) {
return getAuthenticatedAuthState(meData.data); const user = normalizeAuthenticatedUser(meData.data);
if (user) {
return getAuthenticatedAuthState(user);
}
} }
if (meError || meData?.status === 401) { if (meError || meData?.status === 401) {
@@ -63,8 +86,13 @@ export function resolveAuthStateFromMe(params: {
}; };
} }
export function authUserFromMutation( export function validateAuthMutationResponse(
response: loginResponse | registerResponse response: ResponseLike,
expectedStatus: number
): AuthUser | null { ): AuthUser | null {
return response.status === 200 || response.status === 201 ? response.data : null; if (response.status !== expectedStatus) {
return null;
}
return normalizeAuthenticatedUser(response.data);
} }
+11
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();
});
});
+3
View File
@@ -0,0 +1,3 @@
export function setupAuthInterceptors() {
return () => {};
}
+15 -2
View File
@@ -1,4 +1,4 @@
import { ButtonHTMLAttributes, forwardRef } from 'react'; import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react';
interface BaseButtonProps { interface BaseButtonProps {
variant?: 'default' | 'secondary'; variant?: 'default' | 'secondary';
@@ -7,10 +7,11 @@ interface BaseButtonProps {
} }
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>; type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass = const baseClass =
'inline-flex items-center justify-center px-4 py-2 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50'; '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-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`; return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
@@ -30,3 +31,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
); );
Button.displayName = 'Button'; Button.displayName = 'Button';
export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>(
({ variant = 'default', children, className = '', ...props }, ref) => {
return (
<a ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
{children}
</a>
);
}
);
ButtonLink.displayName = 'ButtonLink';
+2 -1
View File
@@ -3,9 +3,10 @@ import { ReactNode } from 'react';
interface FieldProps { interface FieldProps {
label: ReactNode; label: ReactNode;
children: ReactNode; children: ReactNode;
isEditing?: boolean;
} }
export function Field({ label, children }: 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-content-muted">{label}</div> <div className="relative inline-flex gap-2 text-content-muted">{label}</div>
+24 -3
View File
@@ -1,9 +1,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { SettingsIcon, GitIcon } from '../icons'; import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1'; import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { navItems, adminNavItems } from './navigation';
interface NavItem {
path: string;
label: string;
icon: React.ElementType;
title: string;
}
const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
];
const adminSubItems: NavItem[] = [
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
];
function hasPrefix(path: string, prefix: string): boolean { function hasPrefix(path: string, prefix: string): boolean {
return path.startsWith(prefix); return path.startsWith(prefix);
@@ -125,7 +146,7 @@ export default function HamburgerMenu() {
{hasPrefix(location.pathname, '/admin') && ( {hasPrefix(location.pathname, '/admin') && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{adminNavItems.map(item => ( {adminSubItems.map(item => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
+33 -5
View File
@@ -1,18 +1,22 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Link, useLocation, Outlet } from 'react-router-dom'; import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
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 { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings'; import type { ThemeMode } from '../utils/localSettings';
import HamburgerMenu from './HamburgerMenu'; import HamburgerMenu from './HamburgerMenu';
import { getPageTitle } from './navigation';
const themeModes: ThemeMode[] = ['light', 'dark', 'system']; const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuth(); const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
const { themeMode, setThemeMode } = useTheme(); const { themeMode, setThemeMode } = useTheme();
const { data } = useGetMe(isAuthenticated ? {} : undefined);
const fetchedUser =
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
const userData = user ?? fetchedUser;
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -34,12 +38,36 @@ export default function Layout() {
}; };
}, []); }, []);
const currentPageTitle = getPageTitle(location.pathname); const navItems = [
{ path: '/admin/import-results', title: 'Admin - Import' },
{ path: '/admin/import', title: 'Admin - Import' },
{ path: '/admin/users', title: 'Admin - Users' },
{ path: '/admin/logs', title: 'Admin - Logs' },
{ path: '/admin', title: 'Admin - General' },
{ path: '/documents', title: 'Documents' },
{ path: '/progress', title: 'Progress' },
{ path: '/activity', title: 'Activity' },
{ path: '/search', title: 'Search' },
{ path: '/settings', title: 'Settings' },
{ path: '/', title: 'Home' },
];
const currentPageTitle =
navItems.find(item =>
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
)?.title || 'Home';
useEffect(() => { useEffect(() => {
document.title = `AnthoLume - ${currentPageTitle}`; document.title = `AnthoLume - ${currentPageTitle}`;
}, [currentPageTitle]); }, [currentPageTitle]);
if (isCheckingAuth) {
return <div className="text-content-muted">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return ( return (
<div className="min-h-screen bg-canvas"> <div className="min-h-screen bg-canvas">
<div className="flex h-16 w-full items-center justify-between"> <div className="flex h-16 w-full items-center justify-between">
@@ -122,7 +150,7 @@ export default function Layout() {
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)} onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted" className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
> >
<span>{user?.username ?? 'User'}</span> <span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
<span <span
className="text-content transition-transform duration-200" className="text-content transition-transform duration-200"
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }} style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
+2 -2
View File
@@ -27,7 +27,7 @@ export function Pagination({
<button <button
type="button" type="button"
onClick={() => onPageChange(previousPage)} onClick={() => onPageChange(previousPage)}
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-hidden" 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>
@@ -41,7 +41,7 @@ export function Pagination({
<button <button
type="button" type="button"
onClick={() => onPageChange(nextPage)} onClick={() => onPageChange(nextPage)}
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-hidden" 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>
+67 -1
View File
@@ -94,6 +94,45 @@ import { Skeleton } from './components/Skeleton';
<Skeleton variant="rectangular" width="100%" height={200} /> <Skeleton variant="rectangular" width="100%" height={200} />
``` ```
#### `SkeletonText`
Multiple lines of text skeleton:
```tsx
<SkeletonText lines={3} />
<SkeletonText lines={5} className="max-w-md" />
```
#### `SkeletonAvatar`
Avatar placeholder:
```tsx
<SkeletonAvatar size="md" />
<SkeletonAvatar size={56} />
```
#### `SkeletonCard`
Card placeholder with optional elements:
```tsx
// Default card
<SkeletonCard />
// With avatar
<SkeletonCard showAvatar />
// Custom configuration
<SkeletonCard
showAvatar
showTitle
showText
textLines={4}
className="max-w-sm"
/>
```
#### `SkeletonTable` #### `SkeletonTable`
Table placeholder: Table placeholder:
@@ -103,6 +142,33 @@ Table placeholder:
<SkeletonTable rows={10} columns={6} showHeader={false} /> <SkeletonTable rows={10} columns={6} showHeader={false} />
``` ```
#### `SkeletonButton`
Button placeholder:
```tsx
<SkeletonButton width={120} />
<SkeletonButton className="w-full" />
```
#### `PageLoader`
Full-page loading indicator:
```tsx
<PageLoader message="Loading your documents..." />
```
#### `InlineLoader`
Small inline loading spinner:
```tsx
<InlineLoader size="sm" />
<InlineLoader size="md" />
<InlineLoader size="lg" />
```
## Integration with Table Component ## Integration with Table Component
The Table component now supports skeleton loading: The Table component now supports skeleton loading:
@@ -134,4 +200,4 @@ The theme is controlled via Tailwind's `dark:` classes, which respond to the sys
- `clsx` - Utility for constructing className strings - `clsx` - Utility for constructing className strings
- `tailwind-merge` - Merges Tailwind CSS classes intelligently - `tailwind-merge` - Merges Tailwind CSS classes intelligently
- Local icon components in `src/icons/` - `lucide-react` - Icon library used by Toast component
@@ -1,5 +1,4 @@
import type { GraphDataPoint } from '../generated/model'; import type { GraphDataPoint } from '../generated/model';
import { formatUtcDate } from '../utils/formatters';
interface ReadingHistoryGraphProps { interface ReadingHistoryGraphProps {
data: GraphDataPoint[]; data: GraphDataPoint[];
@@ -148,6 +147,14 @@ export function getSVGGraphData(
}; };
} }
function formatDate(dateString: string): string {
const date = new Date(dateString);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) { export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
const svgWidth = 800; const svgWidth = 800;
const svgHeight = 70; const svgHeight = 70;
@@ -192,7 +199,7 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
left: '50%', left: '50%',
}} }}
> >
<span>{formatUtcDate(point.date)}</span> <span>{formatDate(point.date)}</span>
<span>{point.minutes_read} minutes</span> <span>{point.minutes_read} minutes</span>
</div> </div>
</div> </div>
+124
View File
@@ -44,6 +44,75 @@ export function Skeleton({
); );
} }
interface SkeletonTextProps {
lines?: number;
className?: string;
lineClassName?: string;
}
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
className={cn(lineClassName, i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')}
/>
))}
</div>
);
}
interface SkeletonAvatarProps {
size?: number | 'sm' | 'md' | 'lg';
className?: string;
}
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
const sizeMap = {
sm: 32,
md: 40,
lg: 56,
};
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
return <Skeleton variant="circular" width={pixelSize} height={pixelSize} className={className} />;
}
interface SkeletonCardProps {
className?: string;
showAvatar?: boolean;
showTitle?: boolean;
showText?: boolean;
textLines?: number;
}
export function SkeletonCard({
className = '',
showAvatar = false,
showTitle = true,
showText = true,
textLines = 3,
}: SkeletonCardProps) {
return (
<div className={cn('rounded-lg border border-border bg-surface p-4', className)}>
{showAvatar && (
<div className="mb-4 flex items-start gap-4">
<SkeletonAvatar />
<div className="flex-1">
<Skeleton variant="text" className="mb-2 w-3/4" />
<Skeleton variant="text" className="w-1/2" />
</div>
</div>
)}
{showTitle && <Skeleton variant="text" className="mb-4 h-6 w-1/2" />}
{showText && <SkeletonText lines={textLines} />}
</div>
);
}
interface SkeletonTableProps { interface SkeletonTableProps {
rows?: number; rows?: number;
columns?: number; columns?: number;
@@ -89,3 +158,58 @@ export function SkeletonTable({
</div> </div>
); );
} }
interface SkeletonButtonProps {
className?: string;
width?: string | number;
}
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
return (
<Skeleton
variant="rectangular"
height={36}
width={width || '100%'}
className={cn('rounded', className)}
/>
);
}
interface PageLoaderProps {
message?: string;
className?: string;
}
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
return (
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
<div className="relative">
<div className="size-12 animate-spin rounded-full border-4 border-surface-strong border-t-secondary-500" />
</div>
<p className="text-sm font-medium text-content-muted">{message}</p>
</div>
);
}
interface InlineLoaderProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
const sizeMap = {
sm: 'h-4 w-4 border-2',
md: 'h-6 w-6 border-[3px]',
lg: 'h-8 w-8 border-4',
};
return (
<div className={cn('flex items-center justify-center', className)}>
<div
className={`${sizeMap[size]} animate-spin rounded-full border-surface-strong border-t-secondary-500`}
/>
</div>
);
}
export { SkeletonTable as SkeletonTableExport };
+4 -6
View File
@@ -10,14 +10,12 @@ interface TestRow {
const columns: Column<TestRow>[] = [ const columns: Column<TestRow>[] = [
{ {
id: 'name', key: 'name',
header: 'Name', header: 'Name',
render: row => row.name,
}, },
{ {
id: 'role', key: 'role',
header: 'Role', header: 'Role',
render: row => row.role,
}, },
]; ];
@@ -43,9 +41,9 @@ describe('Table', () => {
it('uses a custom render function for column output', () => { it('uses a custom render function for column output', () => {
const customColumns: Column<TestRow>[] = [ const customColumns: Column<TestRow>[] = [
{ {
id: 'name', key: 'name',
header: 'Name', header: 'Name',
render: (row, index) => `${index + 1}. ${row.name.toUpperCase()}`, render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
}, },
]; ];
+56 -13
View File
@@ -1,22 +1,63 @@
import { ReactNode } from 'react'; import React from 'react';
import { SkeletonTable } from './Skeleton'; import { Skeleton } from './Skeleton';
import { cn } from '../utils/cn';
export interface Column<T> { export interface Column<T extends object> {
id: string; key: keyof T;
header: ReactNode; header: string;
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
className?: string; className?: string;
render: (row: T, index: number) => ReactNode;
} }
export interface TableProps<T> { export interface TableProps<T extends object> {
columns: Column<T>[]; columns: Column<T>[];
data: T[]; data: T[];
loading?: boolean; loading?: boolean;
emptyMessage?: ReactNode; emptyMessage?: string;
rowKey?: keyof T | ((row: T) => string); rowKey?: keyof T | ((row: T) => string);
} }
export function Table<T>({ function SkeletonTable({
rows = 5,
columns = 4,
className = '',
}: {
rows?: number;
columns?: number;
className?: string;
}) {
return (
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b border-border">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b border-border last:border-0">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton
variant="text"
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function Table<T extends object>({
columns, columns,
data, data,
loading = false, loading = false,
@@ -39,13 +80,13 @@ export function Table<T>({
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-surface"> <table className="min-w-full bg-surface">
<thead> <thead>
<tr className="border-b border-border"> <tr className="border-b border-border">
{columns.map(column => ( {columns.map(column => (
<th <th
key={column.id} key={String(column.key)}
className={`p-3 text-left text-content-muted ${column.className || ''}`} className={`p-3 text-left text-content-muted ${column.className || ''}`}
> >
{column.header} {column.header}
@@ -65,10 +106,12 @@ export function Table<T>({
<tr key={getRowKey(row, index)} className="border-b border-border"> <tr key={getRowKey(row, index)} className="border-b border-border">
{columns.map(column => ( {columns.map(column => (
<td <td
key={column.id} key={`${getRowKey(row, index)}-${String(column.key)}`}
className={`p-3 text-content ${column.className || ''}`} className={`p-3 text-content ${column.className || ''}`}
> >
{column.render(row, index)} {column.render
? column.render(row[column.key], row, index)
: (row[column.key] as React.ReactNode)}
</td> </td>
))} ))}
</tr> </tr>
-15
View File
@@ -1,15 +0,0 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import { cn } from '../utils/cn';
export const inputClassName =
'w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600';
type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ className, ...props }, ref) => (
<input ref={ref} className={cn(inputClassName, className)} {...props} />
)
);
TextInput.displayName = 'TextInput';
+13 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons'; import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons';
export type ToastType = 'info' | 'warning' | 'error'; export type ToastType = 'info' | 'warning' | 'error';
@@ -11,45 +11,51 @@ export interface ToastProps {
onClose?: (id: string) => void; onClose?: (id: string) => void;
} }
const getToastStyles = (_type: ToastType) => {
const baseStyles = const baseStyles =
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300'; 'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
const typeStyles: Record<ToastType, string> = { const typeStyles = {
info: 'border-secondary-500 bg-secondary-100', info: 'border-secondary-500 bg-secondary-100',
warning: 'border-yellow-500 bg-yellow-100', warning: 'border-yellow-500 bg-yellow-100',
error: 'border-red-500 bg-red-100', error: 'border-red-500 bg-red-100',
}; };
const iconStyles: Record<ToastType, string> = { const iconStyles = {
info: 'text-secondary-700', info: 'text-secondary-700',
warning: 'text-yellow-700', warning: 'text-yellow-700',
error: 'text-red-700', error: 'text-red-700',
}; };
const textStyles: Record<ToastType, string> = { const textStyles = {
info: 'text-secondary-900', info: 'text-secondary-900',
warning: 'text-yellow-900', warning: 'text-yellow-900',
error: 'text-red-900', error: 'text-red-900',
}; };
return { baseStyles, typeStyles, iconStyles, textStyles };
};
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) { export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isAnimatingOut, setIsAnimatingOut] = useState(false); const [isAnimatingOut, setIsAnimatingOut] = useState(false);
const handleClose = useCallback(() => { const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
const handleClose = () => {
setIsAnimatingOut(true); setIsAnimatingOut(true);
setTimeout(() => { setTimeout(() => {
setIsVisible(false); setIsVisible(false);
onClose?.(id); onClose?.(id);
}, 300); }, 300);
}, [id, onClose]); };
useEffect(() => { useEffect(() => {
if (duration > 0) { if (duration > 0) {
const timer = setTimeout(handleClose, duration); const timer = setTimeout(handleClose, duration);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [duration, handleClose]); }, [duration]);
if (!isVisible) { if (!isVisible) {
return null; return null;
+12 -9
View File
@@ -20,31 +20,34 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const showToast = useCallback( const showToast = useCallback(
(message: string, type: ToastType = 'info', duration?: number): string => { (message: string, _type: ToastType = 'info', _duration?: number): string => {
const id = crypto.randomUUID(); const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
setToasts(prev => [...prev, { id, type, message, duration, onClose: removeToast }]); setToasts(prev => [
...prev,
{ id, type: _type, message, duration: _duration, onClose: removeToast },
]);
return id; return id;
}, },
[removeToast] [removeToast]
); );
const showInfo = useCallback( const showInfo = useCallback(
(message: string, duration?: number) => { (message: string, _duration?: number) => {
return showToast(message, 'info', duration); return showToast(message, 'info', _duration);
}, },
[showToast] [showToast]
); );
const showWarning = useCallback( const showWarning = useCallback(
(message: string, duration?: number) => { (message: string, _duration?: number) => {
return showToast(message, 'warning', duration); return showToast(message, 'warning', _duration);
}, },
[showToast] [showToast]
); );
const showError = useCallback( const showError = useCallback(
(message: string, duration?: number) => { (message: string, _duration?: number) => {
return showToast(message, 'error', duration); return showToast(message, 'error', _duration);
}, },
[showToast] [showToast]
); );
+12 -2
View File
@@ -2,13 +2,23 @@
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph'; export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
// Toast components // Toast components
export { Toast } from './Toast';
export { ToastProvider, useToasts } from './ToastContext'; export { ToastProvider, useToasts } from './ToastContext';
export type { ToastType, ToastProps } from './Toast';
// Skeleton components // Skeleton components
export { Skeleton, SkeletonTable } from './Skeleton'; export {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonTable,
SkeletonButton,
PageLoader,
InlineLoader,
} from './Skeleton';
export { LoadingState } from './LoadingState'; export { LoadingState } from './LoadingState';
export { Pagination } from './Pagination'; export { Pagination } from './Pagination';
export { TextInput } from './TextInput';
// Field components // Field components
export { Field, FieldLabel, FieldValue, FieldActions } from './Field'; export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
-46
View File
@@ -1,46 +0,0 @@
import type { ElementType } from 'react';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
export interface NavItem {
path: string;
label: string;
icon: ElementType;
}
export const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: HomeIcon },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
{ path: '/progress', label: 'Progress', icon: ActivityIcon },
{ path: '/activity', label: 'Activity', icon: ActivityIcon },
{ path: '/search', label: 'Search', icon: SearchIcon },
];
export const adminNavItems: NavItem[] = [
{ path: '/admin', label: 'General', icon: SettingsIcon },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon },
];
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
const pageTitles: { path: string; title: string }[] = [
{ path: '/admin/import-results', title: 'Admin - Import' },
{ path: '/admin/import', title: 'Admin - Import' },
{ path: '/admin/users', title: 'Admin - Users' },
{ path: '/admin/logs', title: 'Admin - Logs' },
{ path: '/admin', title: 'Admin - General' },
{ path: '/documents', title: 'Documents' },
{ path: '/progress', title: 'Progress' },
{ path: '/activity', title: 'Activity' },
{ path: '/search', title: 'Search' },
{ path: '/settings', title: 'Settings' },
{ path: '/', title: 'Home' },
];
export function getPageTitle(pathname: string): string {
return (
pageTitles.find(item =>
item.path === '/' ? pathname === item.path : pathname.startsWith(item.path)
)?.title ?? 'Home'
);
}
+69
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');
});
});
+23
View File
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
/**
* Debounces a value by delaying updates until after a specified delay
* @param value The value to debounce
* @param delay The delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
@@ -1,81 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebouncedState } from './useDebouncedState';
describe('useDebouncedState', () => {
afterEach(() => {
vi.useRealTimers();
});
it('returns the initial value for both the input and the debounced value', () => {
const { result } = renderHook(() => useDebouncedState('initial', 300));
const [value, , debounced] = result.current;
expect(value).toBe('initial');
expect(debounced).toBe('initial');
});
it('updates the input immediately but delays the debounced value', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDebouncedState('initial', 300));
act(() => {
const setValue = result.current[1];
setValue('updated');
});
expect(result.current[0]).toBe('updated');
expect(result.current[2]).toBe('initial');
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current[2]).toBe('updated');
});
it('cancels the previous timer when the value changes again', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDebouncedState('first', 300));
act(() => result.current[1]('second'));
act(() => vi.advanceTimersByTime(200));
act(() => result.current[1]('third'));
act(() => vi.advanceTimersByTime(100));
expect(result.current[2]).toBe('first');
act(() => vi.advanceTimersByTime(200));
expect(result.current[2]).toBe('third');
});
it('flush resolves the debounced value immediately, skipping the debounce window', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDebouncedState('initial', 300));
act(() => result.current[1]('updated'));
expect(result.current[2]).toBe('initial');
act(() => {
const flush = result.current[3];
flush();
});
expect(result.current[2]).toBe('updated');
});
it('flush with an argument sets both the input and the resolved value', () => {
const { result } = renderHook(() => useDebouncedState('initial', 300));
act(() => {
const flush = result.current[3];
flush('forced');
});
expect(result.current[0]).toBe('forced');
expect(result.current[2]).toBe('forced');
});
});
-26
View File
@@ -1,26 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
const DEFAULT_DELAY = 300;
/**
* Owns a search/filter input and a debounced view of it. The fourth value, `flush`,
* resolves the debounced value immediately (e.g. on an explicit submit button)
* without waiting for the debounce window to elapse.
*/
export function useDebouncedState<T>(initialValue: T, delay: number = DEFAULT_DELAY) {
const [value, setValue] = useState<T>(initialValue);
const [debounced, setDebounced] = useState<T>(initialValue);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
// Flush - skip the debounce window so an explicit submit resolves the active value right away.
const flush = useCallback((next?: T) => {
if (next !== undefined) setValue(next);
setDebounced(next !== undefined ? next : value);
}, [value]);
return [value, setValue, debounced, flush] as const;
}
+19 -4
View File
@@ -20,7 +20,7 @@ interface UseEpubReaderOptions {
} }
interface UseEpubReaderResult { interface UseEpubReaderResult {
viewerRef: (node: HTMLDivElement | null) => void; viewerRef: (_node: HTMLDivElement | null) => void;
isReady: boolean; isReady: boolean;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -29,6 +29,11 @@ interface UseEpubReaderResult {
nextPage: () => Promise<void>; nextPage: () => Promise<void>;
prevPage: () => Promise<void>; prevPage: () => Promise<void>;
goToHref: (href: string) => Promise<void>; goToHref: (href: string) => Promise<void>;
setTheme: (theme: {
colorScheme?: ReaderColorScheme;
fontFamily?: ReaderFontFamily;
fontSize?: number;
}) => Promise<void>;
} }
export function useEpubReader({ export function useEpubReader({
@@ -176,8 +181,6 @@ export function useEpubReader({
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
}; };
// Init Reader Once - Theme values seed the constructor; the effect below (applyThemeChange) handles later changes, so re-running here would needlessly destroy and recreate the reader.
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [deviceId, deviceName, documentId, initialProgress, viewerNode]); }, [deviceId, deviceName, documentId, initialProgress, viewerNode]);
useEffect(() => { useEffect(() => {
@@ -205,6 +208,17 @@ export function useEpubReader({
await readerRef.current?.displayHref(href); await readerRef.current?.displayHref(href);
}, []); }, []);
const setTheme = useCallback(
async (theme: {
colorScheme?: ReaderColorScheme;
fontFamily?: ReaderFontFamily;
fontSize?: number;
}) => {
await readerRef.current?.applyThemeChange(theme);
},
[]
);
return useMemo( return useMemo(
() => ({ () => ({
viewerRef: setViewerNode, viewerRef: setViewerNode,
@@ -216,7 +230,8 @@ export function useEpubReader({
nextPage, nextPage,
prevPage, prevPage,
goToHref, goToHref,
setTheme,
}), }),
[error, goToHref, isLoading, isReady, nextPage, prevPage, stats, toc] [error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc]
); );
} }
@@ -1,35 +0,0 @@
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
interface ApiResponse {
status: number;
data: unknown;
}
interface ToastMutationOptions {
success: string;
error: string;
onSuccess?: () => void;
}
/**
* Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call,
* centralizing the shared "toast success / toast error / treat non-2xx as failure" pattern.
*/
export function useMutationWithToast() {
const { showInfo, showError } = useToasts();
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
return {
onSuccess: (response: ApiResponse) => {
if (response.status < 200 || response.status >= 300) {
showError(`${error}: ${getErrorMessage(response.data)}`);
return;
}
onSuccess?.();
showInfo(success);
},
onError: (err: unknown) => showError(`${error}: ${getErrorMessage(err)}`),
};
};
}
+3 -134
View File
@@ -1,7 +1,6 @@
@import 'tailwindcss'; @tailwind base;
@tailwind components;
/* Class-Based Dark Mode - v4 defaults to prefers-color-scheme; ThemeProvider toggles `.dark`. */ @tailwind utilities;
@custom-variant dark (&:where(.dark, .dark *));
:root { :root {
--white: 255 255 255; --white: 255 255 255;
@@ -185,137 +184,7 @@
--error-foreground: 255 255 255; --error-foreground: 255 255 255;
} }
@theme inline {
--color-canvas: rgb(var(--canvas));
--color-surface: rgb(var(--surface));
--color-surface-muted: rgb(var(--surface-muted));
--color-surface-strong: rgb(var(--surface-strong));
--color-overlay: rgb(var(--overlay));
--color-content: rgb(var(--content));
--color-content-muted: rgb(var(--content-muted));
--color-content-subtle: rgb(var(--content-subtle));
--color-content-inverse: rgb(var(--content-inverse));
--color-border: rgb(var(--border));
--color-border-muted: rgb(var(--border-muted));
--color-border-strong: rgb(var(--border-strong));
--color-white: rgb(var(--white));
--color-black: rgb(var(--black));
--color-gray-50: rgb(var(--neutral-50));
--color-gray-100: rgb(var(--neutral-100));
--color-gray-200: rgb(var(--neutral-200));
--color-gray-300: rgb(var(--neutral-300));
--color-gray-400: rgb(var(--neutral-400));
--color-gray-500: rgb(var(--neutral-500));
--color-gray-600: rgb(var(--neutral-600));
--color-gray-700: rgb(var(--neutral-700));
--color-gray-800: rgb(var(--neutral-800));
--color-gray-900: rgb(var(--neutral-900));
--color-gray: rgb(var(--neutral-500));
--color-gray-foreground: rgb(var(--neutral-foreground));
--color-purple-50: rgb(var(--primary-50));
--color-purple-100: rgb(var(--primary-100));
--color-purple-200: rgb(var(--primary-200));
--color-purple-300: rgb(var(--primary-300));
--color-purple-400: rgb(var(--primary-400));
--color-purple-500: rgb(var(--primary-500));
--color-purple-600: rgb(var(--primary-600));
--color-purple-700: rgb(var(--primary-700));
--color-purple-800: rgb(var(--primary-800));
--color-purple-900: rgb(var(--primary-900));
--color-purple: rgb(var(--primary-500));
--color-purple-foreground: rgb(var(--primary-foreground));
--color-blue-50: rgb(var(--secondary-50));
--color-blue-100: rgb(var(--secondary-100));
--color-blue-200: rgb(var(--secondary-200));
--color-blue-300: rgb(var(--secondary-300));
--color-blue-400: rgb(var(--secondary-400));
--color-blue-500: rgb(var(--secondary-500));
--color-blue-600: rgb(var(--secondary-600));
--color-blue-700: rgb(var(--secondary-700));
--color-blue-800: rgb(var(--secondary-800));
--color-blue-900: rgb(var(--secondary-900));
--color-blue: rgb(var(--secondary-500));
--color-blue-foreground: rgb(var(--secondary-foreground));
--color-yellow-50: rgb(var(--warning-50));
--color-yellow-100: rgb(var(--warning-100));
--color-yellow-200: rgb(var(--warning-200));
--color-yellow-300: rgb(var(--warning-300));
--color-yellow-400: rgb(var(--warning-400));
--color-yellow-500: rgb(var(--warning-500));
--color-yellow-600: rgb(var(--warning-600));
--color-yellow-700: rgb(var(--warning-700));
--color-yellow-800: rgb(var(--warning-800));
--color-yellow-900: rgb(var(--warning-900));
--color-yellow: rgb(var(--warning-500));
--color-yellow-foreground: rgb(var(--warning-foreground));
--color-red-50: rgb(var(--error-50));
--color-red-100: rgb(var(--error-100));
--color-red-200: rgb(var(--error-200));
--color-red-300: rgb(var(--error-300));
--color-red-400: rgb(var(--error-400));
--color-red-500: rgb(var(--error-500));
--color-red-600: rgb(var(--error-600));
--color-red-700: rgb(var(--error-700));
--color-red-800: rgb(var(--error-800));
--color-red-900: rgb(var(--error-900));
--color-red: rgb(var(--error-500));
--color-red-foreground: rgb(var(--error-foreground));
--color-primary-50: rgb(var(--primary-50));
--color-primary-100: rgb(var(--primary-100));
--color-primary-200: rgb(var(--primary-200));
--color-primary-300: rgb(var(--primary-300));
--color-primary-400: rgb(var(--primary-400));
--color-primary-500: rgb(var(--primary-500));
--color-primary-600: rgb(var(--primary-600));
--color-primary-700: rgb(var(--primary-700));
--color-primary-800: rgb(var(--primary-800));
--color-primary-900: rgb(var(--primary-900));
--color-primary: rgb(var(--primary-500));
--color-primary-foreground: rgb(var(--primary-foreground));
--color-secondary-50: rgb(var(--secondary-50));
--color-secondary-100: rgb(var(--secondary-100));
--color-secondary-200: rgb(var(--secondary-200));
--color-secondary-300: rgb(var(--secondary-300));
--color-secondary-400: rgb(var(--secondary-400));
--color-secondary-500: rgb(var(--secondary-500));
--color-secondary-600: rgb(var(--secondary-600));
--color-secondary-700: rgb(var(--secondary-700));
--color-secondary-800: rgb(var(--secondary-800));
--color-secondary-900: rgb(var(--secondary-900));
--color-secondary: rgb(var(--secondary-500));
--color-secondary-foreground: rgb(var(--secondary-foreground));
--color-tertiary-50: rgb(var(--tertiary-50));
--color-tertiary-100: rgb(var(--tertiary-100));
--color-tertiary-200: rgb(var(--tertiary-200));
--color-tertiary-300: rgb(var(--tertiary-300));
--color-tertiary-400: rgb(var(--tertiary-400));
--color-tertiary-500: rgb(var(--tertiary-500));
--color-tertiary-600: rgb(var(--tertiary-600));
--color-tertiary-700: rgb(var(--tertiary-700));
--color-tertiary-800: rgb(var(--tertiary-800));
--color-tertiary-900: rgb(var(--tertiary-900));
--color-tertiary: rgb(var(--tertiary-500));
--color-tertiary-foreground: rgb(var(--tertiary-foreground));
}
@layer base { @layer base {
/* Preserve v3 Default Border Color - v4 changed the unqualified `border` utility to currentColor. */
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: rgb(var(--border));
}
html, html,
body { body {
overscroll-behavior-y: none; overscroll-behavior-y: none;
+9 -11
View File
@@ -6,13 +6,11 @@ import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
const ACTIVITY_PAGE_SIZE = 25;
export default function ActivityPage() { export default function ActivityPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const documentID = searchParams.get('document') || undefined; const documentID = searchParams.get('document') || undefined;
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const limit = ACTIVITY_PAGE_SIZE; const limit = 25;
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
@@ -29,28 +27,28 @@ export default function ActivityPage() {
const columns: Column<Activity>[] = [ const columns: Column<Activity>[] = [
{ {
id: 'document', key: 'document_id' as const,
header: 'Document', header: 'Document',
render: row => ( render: (_value, row) => (
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline"> <Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
{row.author || 'Unknown'} - {row.title || 'Unknown'} {row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link> </Link>
), ),
}, },
{ {
id: 'start_time', key: 'start_time' as const,
header: 'Time', header: 'Time',
render: row => row.start_time || 'N/A', render: value => String(value || 'N/A'),
}, },
{ {
id: 'duration', key: 'duration' as const,
header: 'Duration', header: 'Duration',
render: row => formatDuration(row.duration ?? 0), render: value => formatDuration(typeof value === 'number' ? value : 0),
}, },
{ {
id: 'end_percentage', key: 'end_percentage' as const,
header: 'Percent', header: 'Percent',
render: row => (typeof row.end_percentage === 'number' ? `${row.end_percentage}%` : '0%'), render: value => (typeof value === 'number' ? `${value}%` : '0%'),
}, },
]; ];
+8 -9
View File
@@ -1,8 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { LoadingState } from '../components';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1'; import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import type { DirectoryItem } from '../generated/model'; 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';
@@ -13,7 +11,6 @@ export default function AdminImportPage() {
const [selectedDirectory, setSelectedDirectory] = useState<string>(''); const [selectedDirectory, setSelectedDirectory] = useState<string>('');
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT'); const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
const { showInfo, showError } = useToasts(); const { showInfo, showError } = useToasts();
const navigate = useNavigate();
const { data: directoryData, isLoading } = useGetImportDirectory( const { data: directoryData, isLoading } = useGetImportDirectory(
currentPath ? { directory: currentPath } : {} currentPath ? { directory: currentPath } : {}
@@ -22,7 +19,7 @@ export default function AdminImportPage() {
const postImport = usePostImport(); const postImport = usePostImport();
const directoryResponse = const directoryResponse =
directoryData?.status === 200 ? directoryData.data : null; directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null;
const directories = directoryResponse?.items ?? []; const directories = directoryResponse?.items ?? [];
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data'; const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
@@ -51,7 +48,9 @@ export default function AdminImportPage() {
{ {
onSuccess: _response => { onSuccess: _response => {
showInfo('Import completed successfully'); showInfo('Import completed successfully');
navigate('/admin/import-results'); setTimeout(() => {
window.location.href = '/admin/import-results';
}, 1500);
}, },
onError: error => { onError: error => {
showError('Import failed: ' + getErrorMessage(error)); showError('Import failed: ' + getErrorMessage(error));
@@ -65,13 +64,13 @@ export default function AdminImportPage() {
}; };
if (isLoading && !currentPath) { if (isLoading && !currentPath) {
return <LoadingState />; 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-sm"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<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-surface p-4 text-content-muted shadow-lg">
<p className="text-lg font-semibold text-content">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}>
@@ -123,7 +122,7 @@ 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-sm"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-surface text-sm leading-normal text-content"> <table className="min-w-full bg-surface text-sm leading-normal text-content">
<thead className="text-content-muted"> <thead className="text-content-muted">
<tr> <tr>
+16 -13
View File
@@ -1,28 +1,31 @@
import { useGetImportResults } from '../generated/anthoLumeAPIV1'; import { useGetImportResults } from '../generated/anthoLumeAPIV1';
import { LoadingState } from '../components'; import type { ImportResult, ImportResultsResponse } from '../generated/model';
import type { ImportResult } 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 = const results =
resultsData?.status === 200 ? resultsData.data.results || [] : []; resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
if (isLoading) { if (isLoading) {
return <LoadingState />; 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-sm"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-surface text-sm leading-normal text-content"> <table className="min-w-full bg-surface text-sm leading-normal text-content">
<thead className="text-content-muted"> <thead className="text-content-muted">
<tr> <tr>
<th className="border-b border-border p-3 text-left font-normal uppercase"> <th className="border-b border-border p-3 text-left font-normal uppercase">
Document Document
</th> </th>
<th className="border-b border-border p-3 text-left font-normal uppercase">Status</th> <th className="border-b border-border p-3 text-left font-normal uppercase">
<th className="border-b border-border p-3 text-left font-normal uppercase">Error</th> Status
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Error
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -34,14 +37,14 @@ export default function AdminImportResultsPage() {
</tr> </tr>
) : ( ) : (
results.map((result: ImportResult, index: number) => ( results.map((result: ImportResult, index: number) => (
<tr key={result.path ?? index}> <tr key={index}>
<td className="grid grid-cols-[4rem_auto] border-b border-border p-3"> <td
className="grid border-b border-border p-3"
style={{ gridTemplateColumns: '4rem auto' }}
>
<span className="text-content-muted">Name:</span> <span className="text-content-muted">Name:</span>
{result.id ? ( {result.id ? (
<Link <Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
to={`/documents/${result.id}`}
className="text-secondary-600 hover:underline"
>
{result.name} {result.name}
</Link> </Link>
) : ( ) : (
+24 -13
View File
@@ -1,20 +1,27 @@
import { SyntheticEvent } 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 { LoadingState, TextInput } from '../components'; import { LoadingState } from '../components';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebounce } from '../hooks/useDebounce';
import { Search2Icon } from '../icons'; import { Search2Icon } from '../icons';
export default function AdminLogsPage() { export default function AdminLogsPage() {
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300); const [filter, setFilter] = useState('');
const [activeFilter, setActiveFilter] = useState('');
const debouncedFilter = useDebounce(filter, 300);
useEffect(() => {
setActiveFilter(debouncedFilter);
}, [debouncedFilter]);
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {}); const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
const logs = logsData?.status === 200 ? (logsData.data.logs ?? []) : []; const logs = logsData?.status === 200 ? ((logsData.data as LogsResponse).logs ?? []) : [];
const handleFilterSubmit = (e: SyntheticEvent) => { const handleFilterSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
flushFilter(); setActiveFilter(filter);
}; };
return ( return (
@@ -23,29 +30,33 @@ export default function AdminLogsPage() {
<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-border bg-surface px-3 text-sm text-content-muted shadow-xs"> <span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
<Search2Icon size={15} hoverable={false} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<TextInput <input
type="text" type="text"
value={filter} value={filter}
onChange={e => setFilter(e.target.value)} onChange={e => setFilter(e.target.value)}
className="p-2" 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>
</div> </div>
<Button variant="secondary" type="submit" className="w-full lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">
Filter Filter
</Button> </Button>
</div>
</form> </form>
</div> </div>
<div className="flex w-full flex-col-reverse overflow-scroll font-mono text-content"> <div
className="flex w-full flex-col-reverse overflow-scroll text-content"
style={{ fontFamily: 'monospace' }}
>
{isLoading ? ( {isLoading ? (
<LoadingState className="min-h-40 w-full" /> <LoadingState className="min-h-40 w-full" />
) : ( ) : (
// Key By Index - Log lines have no stable id and can repeat; this list is append-only and fully replaced on refetch.
logs.map((log, index) => ( logs.map((log, index) => (
<span key={index} className="whitespace-nowrap hover:whitespace-pre"> <span key={index} className="whitespace-nowrap hover:whitespace-pre">
{typeof log === 'string' ? log : JSON.stringify(log)} {typeof log === 'string' ? log : JSON.stringify(log)}
+64 -34
View File
@@ -1,11 +1,8 @@
import { useState, SyntheticEvent } from 'react'; import { useState, FormEvent } from 'react';
import { LoadingState } from '../components';
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1'; import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import { streamResponseToFile, backupFilename } from '../utils/download';
interface BackupTypes { interface BackupTypes {
covers: boolean; covers: boolean;
@@ -16,7 +13,6 @@ export default function AdminPage() {
const { isLoading } = useGetAdmin(); const { isLoading } = useGetAdmin();
const postAdminAction = usePostAdminAction(); const postAdminAction = usePostAdminAction();
const { showInfo, showError, removeToast } = useToasts(); const { showInfo, showError, removeToast } = useToasts();
const toastMutationOptions = useMutationWithToast();
const [backupTypes, setBackupTypes] = useState<BackupTypes>({ const [backupTypes, setBackupTypes] = useState<BackupTypes>({
covers: false, covers: false,
@@ -24,7 +20,7 @@ export default function AdminPage() {
}); });
const [restoreFile, setRestoreFile] = useState<File | null>(null); const [restoreFile, setRestoreFile] = useState<File | null>(null);
const handleBackupSubmit = async (e: SyntheticEvent) => { const handleBackupSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
const backupTypesList: string[] = []; const backupTypesList: string[] = [];
if (backupTypes.covers) backupTypesList.push('COVERS'); if (backupTypes.covers) backupTypesList.push('COVERS');
@@ -35,7 +31,6 @@ export default function AdminPage() {
formData.append('action', 'BACKUP'); formData.append('action', 'BACKUP');
backupTypesList.forEach(value => formData.append('backup_types', value)); backupTypesList.forEach(value => formData.append('backup_types', value));
// Streaming Fetch - The generated client buffers; the backup can be large, so this endpoint intentionally uses a raw streaming download.
const response = await fetch('/api/v1/admin', { const response = await fetch('/api/v1/admin', {
method: 'POST', method: 'POST',
body: formData, body: formData,
@@ -45,25 +40,46 @@ export default function AdminPage() {
throw new Error('Backup failed: ' + response.statusText); throw new Error('Backup failed: ' + response.statusText);
} }
const completed = await streamResponseToFile(response, { const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
suggestedName: backupFilename(),
mimeType: 'application/zip', if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
extension: '.zip', try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
}); });
if (completed) { const writable = await handle.createWritable();
const reader = response.body?.getReader();
if (!reader) throw new Error('Unable to read response');
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
showInfo('Backup completed successfully'); showInfo('Backup completed successfully');
} catch (err) {
if ((err as Error).name !== 'AbortError') {
showError('Backup failed: ' + (err as Error).message);
}
}
} else {
showError(
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
);
} }
} catch (error) { } catch (error) {
showError('Backup failed: ' + getErrorMessage(error)); showError('Backup failed: ' + getErrorMessage(error));
} }
}; };
const handleRestoreSubmit = async (e: SyntheticEvent) => { const handleRestoreSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!restoreFile) return; if (!restoreFile) return;
// Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator.
const startedToastId = showInfo('Restore started', 0); const startedToastId = showInfo('Restore started', 0);
try { try {
@@ -91,25 +107,25 @@ export default function AdminPage() {
const handleMetadataMatch = () => { const handleMetadataMatch = () => {
postAdminAction.mutate( postAdminAction.mutate(
{ data: { action: 'METADATA_MATCH' } }, { data: { action: 'METADATA_MATCH' } },
toastMutationOptions({ {
success: 'Metadata matching started', onSuccess: () => showInfo('Metadata matching started'),
error: 'Failed to start metadata matching', onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
}) }
); );
}; };
const handleCacheTables = () => { const handleCacheTables = () => {
postAdminAction.mutate( postAdminAction.mutate(
{ data: { action: 'CACHE_TABLES' } }, { data: { action: 'CACHE_TABLES' } },
toastMutationOptions({ {
success: 'Cache tables started', onSuccess: () => showInfo('Cache tables started'),
error: 'Failed to start cache tables', onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
}) }
); );
}; };
if (isLoading) { if (isLoading) {
return <LoadingState />; return <div className="text-content-muted">Loading...</div>;
} }
return ( return (
@@ -138,8 +154,8 @@ export default function AdminPage() {
<label htmlFor="backup_documents">Documents</label> <label htmlFor="backup_documents">Documents</label>
</div> </div>
</div> </div>
<div className="w-40"> <div className="h-10 w-40">
<Button variant="secondary" type="submit" className="w-full"> <Button variant="secondary" type="submit">
Backup Backup
</Button> </Button>
</div> </div>
@@ -154,8 +170,8 @@ export default function AdminPage() {
className="w-full" className="w-full"
/> />
</div> </div>
<div className="w-40"> <div className="h-10 w-40">
<Button variant="secondary" type="submit" className="w-full" disabled={!restoreFile}> <Button variant="secondary" type="submit" disabled={!restoreFile}>
Restore Restore
</Button> </Button>
</div> </div>
@@ -164,21 +180,35 @@ export default function AdminPage() {
</div> </div>
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg"> <div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="mb-4 text-lg font-semibold text-content">Tasks</p> <p className="text-lg font-semibold text-content">Tasks</p>
<ul className="flex flex-col gap-3 text-sm text-content"> <table className="min-w-full bg-surface text-sm text-content">
<li className="flex items-center justify-between gap-4"> <tbody>
<tr>
<td className="pl-0">
<p>Metadata Matching</p> <p>Metadata Matching</p>
</td>
<td className="float-right py-2">
<div className="h-10 w-40 text-base">
<Button variant="secondary" onClick={handleMetadataMatch}> <Button variant="secondary" onClick={handleMetadataMatch}>
Run Run
</Button> </Button>
</li> </div>
<li className="flex items-center justify-between gap-4"> </td>
</tr>
<tr>
<td>
<p>Cache Tables</p> <p>Cache Tables</p>
</td>
<td className="float-right py-2">
<div className="h-10 w-40 text-base">
<Button variant="secondary" onClick={handleCacheTables}> <Button variant="secondary" onClick={handleCacheTables}>
Run Run
</Button> </Button>
</li> </div>
</ul> </td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
); );
+132 -129
View File
@@ -1,27 +1,23 @@
import { useState, SyntheticEvent } from 'react'; import { useState, FormEvent } from 'react';
import { LoadingState, TextInput } from '../components';
import { Button } from '../components/Button';
import { Table, type Column } from '../components/Table';
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1'; import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
import type { User } from '../generated/model'; import type { User, UsersResponse } from '../generated/model';
import { AddIcon, DeleteIcon } from '../icons'; import { AddIcon, DeleteIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { data: usersData, isLoading, refetch } = useGetUsers({}); const { data: usersData, isLoading, refetch } = useGetUsers({});
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
const toastMutationOptions = useMutationWithToast(); const { showInfo, showError } = useToasts();
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState(''); const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false); const [newIsAdmin, setNewIsAdmin] = useState(false);
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetPassword, setResetPassword] = useState('');
const users = usersData?.status === 200 ? (usersData.data.users ?? []) : []; const users = usersData?.status === 200 ? ((usersData.data as UsersResponse).users ?? []) : [];
const handleCreateUser = (e: SyntheticEvent) => { const handleCreateUser = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newUsername || !newPassword) return; if (!newUsername || !newPassword) return;
@@ -34,17 +30,22 @@ export default function AdminUsersPage() {
is_admin: newIsAdmin, is_admin: newIsAdmin,
}, },
}, },
toastMutationOptions({ {
success: 'User created successfully', onSuccess: response => {
error: 'Failed to create user', if (response.status < 200 || response.status >= 300) {
onSuccess: () => { showError('Failed to create user: ' + getErrorMessage(response.data));
return;
}
showInfo('User created successfully');
setShowAddForm(false); setShowAddForm(false);
setNewUsername(''); setNewUsername('');
setNewPassword(''); setNewPassword('');
setNewIsAdmin(false); setNewIsAdmin(false);
refetch(); refetch();
}, },
}) onError: error => showError('Failed to create user: ' + getErrorMessage(error)),
}
); );
}; };
@@ -53,11 +54,18 @@ export default function AdminUsersPage() {
{ {
data: { operation: 'DELETE', user: userId }, data: { operation: 'DELETE', user: userId },
}, },
toastMutationOptions({ {
success: 'User deleted successfully', onSuccess: response => {
error: 'Failed to delete user', if (response.status < 200 || response.status >= 300) {
onSuccess: refetch, showError('Failed to delete user: ' + getErrorMessage(response.data));
}) return;
}
showInfo('User deleted successfully');
refetch();
},
onError: error => showError('Failed to delete user: ' + getErrorMessage(error)),
}
); );
}; };
@@ -68,11 +76,18 @@ export default function AdminUsersPage() {
{ {
data: { operation: 'UPDATE', user: userId, password }, data: { operation: 'UPDATE', user: userId, password },
}, },
toastMutationOptions({ {
success: 'Password updated successfully', onSuccess: response => {
error: 'Failed to update password', if (response.status < 200 || response.status >= 300) {
onSuccess: refetch, showError('Failed to update password: ' + getErrorMessage(response.data));
}) return;
}
showInfo('Password updated successfully');
refetch();
},
onError: error => showError('Failed to update password: ' + getErrorMessage(error)),
}
); );
}; };
@@ -81,80 +96,23 @@ export default function AdminUsersPage() {
{ {
data: { operation: 'UPDATE', user: userId, is_admin: isAdmin }, data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
}, },
toastMutationOptions({ {
success: `User permissions updated to ${isAdmin ? 'admin' : 'user'}`, onSuccess: response => {
error: 'Failed to update admin status', if (response.status < 200 || response.status >= 300) {
onSuccess: refetch, showError('Failed to update admin status: ' + getErrorMessage(response.data));
}) return;
}
showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`);
refetch();
},
onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)),
}
); );
}; };
const permissionButtonClass = (active: boolean) =>
`rounded-md px-2 py-1 ${
active
? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-surface-strong text-content'
}`;
const userColumns: Column<User>[] = [
{
id: 'actions',
className: 'w-12',
header: (
<button onClick={() => setShowAddForm(!showAddForm)} aria-label="Add user">
<AddIcon size={20} />
</button>
),
render: user => (
<button onClick={() => handleDeleteUser(user.id)} aria-label="Delete user">
<DeleteIcon size={20} />
</button>
),
},
{ id: 'user', header: 'User', render: user => user.id },
{
id: 'password',
header: 'Password',
render: user => (
<button
onClick={() => {
setResetUserId(user.id);
setResetPassword('');
}}
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
>
Reset
</button>
),
},
{
id: 'permissions',
header: 'Permissions',
className: 'text-center',
render: user => (
<div className="flex justify-center gap-2">
<button
onClick={() => handleToggleAdmin(user.id, true)}
disabled={user.admin}
className={permissionButtonClass(user.admin)}
>
admin
</button>
<button
onClick={() => handleToggleAdmin(user.id, false)}
disabled={!user.admin}
className={permissionButtonClass(!user.admin)}
>
user
</button>
</div>
),
},
{ id: 'created', header: 'Created', className: 'w-48', render: user => user.created_at },
];
if (isLoading) { if (isLoading) {
return <LoadingState />; return <div className="text-content-muted">Loading...</div>;
} }
return ( return (
@@ -195,42 +153,87 @@ export default function AdminUsersPage() {
</div> </div>
)} )}
<Table columns={userColumns} data={users} rowKey="id" /> <div className="min-w-full overflow-scroll rounded shadow">
<table className="min-w-full bg-surface text-sm leading-normal text-content">
{resetUserId && ( <thead className="text-content-muted">
<div <tr>
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50" <th className="w-12 border-b border-border p-3 text-left font-normal uppercase">
onClick={() => setResetUserId(null)} <button onClick={() => setShowAddForm(!showAddForm)}>
> <AddIcon size={20} />
<form </button>
className="w-80 rounded bg-surface p-4 shadow-lg" </th>
onClick={e => e.stopPropagation()} <th className="border-b border-border p-3 text-left font-normal uppercase">User</th>
onSubmit={e => { <th className="border-b border-border p-3 text-left font-normal uppercase">Password</th>
e.preventDefault(); <th className="border-b border-border p-3 text-center font-normal uppercase">
if (!resetPassword) return; Permissions
handleUpdatePassword(resetUserId, resetPassword); </th>
setResetUserId(null); <th className="w-48 border-b border-border p-3 text-left font-normal uppercase">
Created
</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td className="p-3 text-center" colSpan={5}>
No Results
</td>
</tr>
) : (
users.map((user: User) => (
<tr key={user.id}>
<td className="relative cursor-pointer border-b border-border p-3 text-content-muted">
<button onClick={() => handleDeleteUser(user.id)}>
<DeleteIcon size={20} />
</button>
</td>
<td className="border-b border-border p-3">
<p>{user.id}</p>
</td>
<td className="border-b border-border px-3">
<button
onClick={() => {
const password = prompt(`Enter new password for ${user.id}`);
if (password) handleUpdatePassword(user.id, password);
}} }}
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
> >
<p className="mb-3 text-content">Reset password for {resetUserId}</p> Reset
<TextInput </button>
type="password" </td>
value={resetPassword} <td className="flex min-w-40 justify-center gap-2 border-b border-border p-3 text-center">
onChange={e => setResetPassword(e.target.value)} <button
placeholder="New password" onClick={() => handleToggleAdmin(user.id, true)}
autoFocus disabled={user.admin}
/> className={`rounded-md px-2 py-1 ${
<div className="mt-3 flex justify-end gap-2"> user.admin
<Button type="button" variant="secondary" onClick={() => setResetUserId(null)}> ? 'cursor-default bg-content text-content-inverse'
Cancel : 'cursor-pointer bg-surface-strong text-content'
</Button> }`}
<Button type="submit" disabled={!resetPassword}> >
Save admin
</Button> </button>
</div> <button
</form> onClick={() => handleToggleAdmin(user.id, false)}
</div> disabled={!user.admin}
className={`rounded-md px-2 py-1 ${
!user.admin
? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-surface-strong text-content'
}`}
>
user
</button>
</td>
<td className="border-b border-border p-3">
<p>{user.created_at}</p>
</td>
</tr>
))
)} )}
</tbody>
</table>
</div>
</div> </div>
); );
} }
-105
View File
@@ -1,105 +0,0 @@
import { SyntheticEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../components/Button';
import { TextInput } from '../components';
interface AuthFormViewProps {
username: string;
password: string;
isLoading: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void | Promise<void>;
submitLabel: string;
submittingLabel: string;
inputsDisabled?: boolean;
footer: ReactNode;
}
export function AuthFormView({
username,
password,
isLoading,
onUsernameChange,
onPasswordChange,
onSubmit,
submitLabel,
submittingLabel,
inputsDisabled = false,
footer,
}: AuthFormViewProps) {
return (
<div className="min-h-screen bg-canvas text-content">
<div className="flex w-full flex-wrap">
<div className="flex w-full flex-col md:w-1/2">
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
<p className="text-center text-3xl">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
<div className="flex flex-col pt-4">
<div className="relative flex">
<TextInput
type="text"
value={username}
onChange={e => onUsernameChange(e.target.value)}
placeholder="Username"
required
disabled={isLoading || inputsDisabled}
/>
</div>
</div>
<div className="mb-12 flex flex-col pt-4">
<div className="relative flex">
<TextInput
type="password"
value={password}
onChange={e => onPasswordChange(e.target.value)}
placeholder="Password"
required
disabled={isLoading || inputsDisabled}
/>
</div>
</div>
<Button
variant="secondary"
type="submit"
disabled={isLoading || inputsDisabled}
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-hidden focus:ring-2 disabled:opacity-50"
>
{isLoading ? submittingLabel : submitLabel}
</Button>
</form>
<div className="py-12 text-center">{footer}</div>
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
<span className="text-content-muted">AnthoLume</span>
</div>
</div>
</div>
</div>
);
}
export function authFormFooter(
primaryLink: { to: string; text: string },
showPrimary: boolean
) {
return (
<>
{showPrimary && (
<p>
<Link to={primaryLink.to} className="font-semibold underline">
{primaryLink.text}
</Link>
</p>
)}
<p className={showPrimary ? 'mt-4' : ''}>
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useState } from 'react';
import { useToasts } from '../components/ToastContext';
import {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonTable,
SkeletonButton,
PageLoader,
InlineLoader,
} from '../components/Skeleton';
export default function ComponentDemoPage() {
const { showInfo, showWarning, showError, showToast } = useToasts();
const [isLoading, setIsLoading] = useState(false);
const handleDemoClick = () => {
setIsLoading(true);
showInfo('Starting demo operation...');
setTimeout(() => {
setIsLoading(false);
showInfo('Demo operation completed successfully!');
}, 2000);
};
const handleErrorClick = () => {
showError('This is a sample error message');
};
const handleWarningClick = () => {
showWarning('This is a sample warning message', 10000);
};
const handleCustomToast = () => {
showToast('Custom toast message', 'info', 3000);
};
return (
<div className="space-y-8 p-4 text-content">
<h1 className="text-2xl font-bold">UI Components Demo</h1>
<section className="rounded-lg bg-surface p-6 shadow">
<h2 className="mb-4 text-xl font-semibold">Toast Notifications</h2>
<div className="flex flex-wrap gap-4">
<button
onClick={handleDemoClick}
disabled={isLoading}
className="rounded bg-secondary-500 px-4 py-2 text-secondary-foreground hover:bg-secondary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
</button>
<button
onClick={handleWarningClick}
className="rounded bg-yellow-500 px-4 py-2 text-white hover:bg-yellow-600"
>
Show Warning Toast (10s)
</button>
<button
onClick={handleErrorClick}
className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600"
>
Show Error Toast
</button>
<button
onClick={handleCustomToast}
className="rounded bg-primary-500 px-4 py-2 text-primary-foreground hover:bg-primary-600"
>
Show Custom Toast
</button>
</div>
</section>
<section className="rounded-lg bg-surface p-6 shadow">
<h2 className="mb-4 text-xl font-semibold">Skeleton Loading Components</h2>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Basic Skeletons</h3>
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton variant="text" className="w-3/4" />
<Skeleton variant="text" className="w-1/2" />
<div className="flex items-center gap-4">
<Skeleton variant="circular" width={40} height={40} />
<Skeleton variant="rectangular" width={100} height={40} />
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Skeleton Text</h3>
<SkeletonText lines={3} />
<SkeletonText lines={5} className="max-w-md" />
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Skeleton Avatar</h3>
<div className="flex items-center gap-4">
<SkeletonAvatar size="sm" />
<SkeletonAvatar size="md" />
<SkeletonAvatar size="lg" />
<SkeletonAvatar size={72} />
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-content-muted">Skeleton Button</h3>
<div className="flex flex-wrap gap-2">
<SkeletonButton width={120} />
<SkeletonButton className="w-full max-w-xs" />
</div>
</div>
</div>
</section>
<section className="rounded-lg bg-surface p-6 shadow">
<h2 className="mb-4 text-xl font-semibold">Skeleton Cards</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<SkeletonCard />
<SkeletonCard showAvatar />
<SkeletonCard showAvatar showTitle showText textLines={4} />
</div>
</section>
<section className="rounded-lg bg-surface p-6 shadow">
<h2 className="mb-4 text-xl font-semibold">Skeleton Table</h2>
<SkeletonTable rows={5} columns={4} />
</section>
<section className="rounded-lg bg-surface p-6 shadow">
<h2 className="mb-4 text-xl font-semibold">Page Loader</h2>
<PageLoader message="Loading demo content..." />
</section>
<section className="rounded-lg bg-surface p-6 shadow">
<h2 className="mb-4 text-xl font-semibold">Inline Loader</h2>
<div className="flex items-center gap-8">
<div className="text-center">
<InlineLoader size="sm" />
<p className="mt-2 text-sm text-content-muted">Small</p>
</div>
<div className="text-center">
<InlineLoader size="md" />
<p className="mt-2 text-sm text-content-muted">Medium</p>
</div>
<div className="text-center">
<InlineLoader size="lg" />
<p className="mt-2 text-sm text-content-muted">Large</p>
</div>
</div>
</section>
</div>
);
}
+309 -137
View File
@@ -6,163 +6,123 @@ import {
useEditDocument, useEditDocument,
getGetDocumentQueryKey, getGetDocumentQueryKey,
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import type { EditDocumentBody } from '../generated/model'; import { Document } from '../generated/model/document';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { getErrorMessage } from '../utils/errors'; import {
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons'; DeleteIcon,
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components'; ActivityIcon,
import { useToasts } from '../components/ToastContext'; SearchIcon,
DownloadIcon,
EditIcon,
InfoIcon,
CloseIcon,
CheckIcon,
} from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content'; const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
const popupClassName = const popupClassName = 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
'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 = const editInputClassName =
'w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600'; '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';
interface EditableFieldProps {
label: string;
value: string;
multiline?: boolean;
valueClassName?: string;
onSave: (value: string) => Promise<boolean>;
}
function EditableField({
label,
value,
multiline = false,
valueClassName,
onSave,
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(value);
const startEdit = () => {
setDraft(value);
setIsEditing(true);
};
// Keep Editor Open On Failure - Only close once the save actually succeeds so a failed edit isn't silently lost.
const confirm = async () => {
if (await onSave(draft)) setIsEditing(false);
};
return (
<Field
label={
<>
<FieldLabel>{label}</FieldLabel>
<FieldActions>
{isEditing ? (
<div className="flex gap-1">
<button
type="button"
onClick={() => setIsEditing(false)}
className={iconButtonClassName}
aria-label="Cancel edit"
>
<CloseIcon size={18} />
</button>
<button
type="button"
onClick={confirm}
className={iconButtonClassName}
aria-label="Confirm edit"
>
<CheckIcon size={18} />
</button>
</div>
) : (
<button
type="button"
onClick={startEdit}
className={iconButtonClassName}
aria-label={`Edit ${label.toLowerCase()}`}
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditing ? (
<div className="relative mt-1 flex gap-2">
{multiline ? (
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
className="h-32 w-full grow rounded border border-border bg-surface-muted p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
rows={5}
/>
) : (
<input
type="text"
value={draft}
onChange={e => setDraft(e.target.value)}
className={editInputClassName}
/>
)}
</div>
) : (
<FieldValue className={valueClassName}>{value || 'N/A'}</FieldValue>
)}
</Field>
);
}
export default function DocumentPage() { export default function DocumentPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: docData, isLoading: docLoading } = useGetDocument(id || ''); const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
const editMutation = useEditDocument(); const editMutation = useEditDocument();
const { showError } = useToasts();
const [showEditCover, setShowEditCover] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showIdentify, setShowIdentify] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isEditingAuthor, setIsEditingAuthor] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false); const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editAuthor, setEditAuthor] = useState('');
const [editDescription, setEditDescription] = useState('');
if (docLoading) { if (docLoading) {
return <LoadingState />; return <div className="text-content-muted">Loading...</div>;
} }
if (!docData || docData.status !== 200) { if (!docData || docData.status !== 200) {
return <div className="text-content-muted">Document not found</div>; return <div className="text-content-muted">Document not found</div>;
} }
const document = docData.data.document; const document = docData.data.document as Document;
if (!document) {
return <div className="text-content-muted">Document not found</div>;
}
const percentage = document.percentage ?? 0; const percentage = document.percentage ?? 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);
const save = async (data: EditDocumentBody): Promise<boolean> => { const startEditing = (field: 'title' | 'author' | 'description') => {
try { if (field === 'title') setEditTitle(document.title);
const response = await editMutation.mutateAsync({ id: document.id, data }); if (field === 'author') setEditAuthor(document.author);
if (response.status !== 200) { if (field === 'description') setEditDescription(document.description || '');
showError('Failed to save: ' + getErrorMessage(response.data)); };
return false;
} const saveTitle = () => {
editMutation.mutate(
{ id: document.id, data: { title: editTitle } },
{
onSuccess: response => {
setIsEditingTitle(false);
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response); queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
return true; },
} catch (err) { onError: () => setIsEditingTitle(false),
showError('Failed to save: ' + getErrorMessage(err));
return false;
} }
);
};
const saveAuthor = () => {
editMutation.mutate(
{ id: document.id, data: { author: editAuthor } },
{
onSuccess: response => {
setIsEditingAuthor(false);
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingAuthor(false),
}
);
};
const saveDescription = () => {
editMutation.mutate(
{ id: document.id, data: { description: editDescription } },
{
onSuccess: response => {
setIsEditingDescription(false);
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
},
onError: () => setIsEditingDescription(false),
}
);
}; };
return ( return (
<div className="relative size-full"> <div className="relative size-full">
<div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg"> <div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80"> <div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
<img <img
className="w-full rounded object-fill" className="w-full rounded object-fill"
src={`/api/v1/documents/${document.id}/cover`} src={`/api/v1/documents/${document.id}/cover`}
alt={`${document.title} cover`} alt={`${document.title} cover`}
/> />
</label>
{document.filepath && ( {document.filepath && (
<a <a
href={`/reader/${document.id}`} href={`/reader/${document.id}`}
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500" 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>
@@ -180,7 +140,66 @@ export default function DocumentPage() {
</div> </div>
</div> </div>
<div className="relative">
<input
type="checkbox"
id="edit-cover-checkbox"
className="hidden"
checked={showEditCover}
onChange={e => setShowEditCover(e.target.checked)}
/>
<div
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 ${popupClassName} ${
showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<form className="flex w-72 flex-col gap-2 text-sm">
<input type="file" id="cover_file" name="cover_file" className={popupInputClassName} />
<button
type="submit"
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
>
Upload Cover
</button>
</form>
<form className="flex w-72 flex-col gap-2 text-sm">
<input type="checkbox" checked id="remove_cover" name="remove_cover" className="hidden" />
<button
type="submit"
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
>
Remove Cover
</button>
</form>
</div>
</div>
<div className="relative my-auto flex grow justify-between text-content-muted"> <div className="relative my-auto flex grow justify-between text-content-muted">
<div className="relative">
<button
type="button"
onClick={() => setShowDelete(!showDelete)}
className={iconButtonClassName}
aria-label="Delete"
>
<DeleteIcon size={28} />
</button>
<div
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<form className="w-24 text-sm">
<button
type="submit"
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
>
Delete
</button>
</form>
</div>
</div>
<a <a
href={`/activity?document=${document.id}`} href={`/activity?document=${document.id}`}
aria-label="Activity" aria-label="Activity"
@@ -189,6 +208,55 @@ export default function DocumentPage() {
<ActivityIcon size={28} /> <ActivityIcon size={28} />
</a> </a>
<div className="relative">
<button
type="button"
onClick={() => setShowIdentify(!showIdentify)}
aria-label="Identify"
className={iconButtonClassName}
>
<SearchIcon size={28} />
</button>
<div
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<form className="flex flex-col gap-2 text-sm">
<input
type="text"
id="title"
name="title"
placeholder="Title"
defaultValue={document.title}
className={popupInputClassName}
/>
<input
type="text"
id="author"
name="author"
placeholder="Author"
defaultValue={document.author}
className={popupInputClassName}
/>
<input
type="text"
id="isbn"
name="isbn"
placeholder="ISBN 10 / ISBN 13"
defaultValue={document.isbn13 || document.isbn10}
className={popupInputClassName}
/>
<button
type="submit"
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
>
Identify
</button>
</form>
</div>
</div>
{document.filepath ? ( {document.filepath ? (
<a <a
href={`/api/v1/documents/${document.id}/file`} href={`/api/v1/documents/${document.id}/file`}
@@ -207,17 +275,87 @@ export default function DocumentPage() {
</div> </div>
<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">
<EditableField <Field
label="Title" isEditing={isEditingTitle}
value={document.title} label={
onSave={value => save({ title: value })} <>
/> <FieldLabel>Title</FieldLabel>
<FieldActions>
{isEditingTitle ? (
<div className="flex gap-1">
<button type="button" onClick={() => setIsEditingTitle(false)} className={iconButtonClassName} aria-label="Cancel edit">
<CloseIcon size={18} />
</button>
<button type="button" onClick={saveTitle} className={iconButtonClassName} aria-label="Confirm edit">
<CheckIcon size={18} />
</button>
</div>
) : (
<button
type="button"
onClick={() => {
startEditing('title');
setIsEditingTitle(true);
}}
className={iconButtonClassName}
aria-label="Edit title"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditingTitle ? (
<div className="relative mt-1 flex gap-2">
<input type="text" value={editTitle} onChange={e => setEditTitle(e.target.value)} className={editInputClassName} />
</div>
) : (
<FieldValue>{document.title}</FieldValue>
)}
</Field>
<EditableField <Field
label="Author" isEditing={isEditingAuthor}
value={document.author} label={
onSave={value => save({ author: value })} <>
/> <FieldLabel>Author</FieldLabel>
<FieldActions>
{isEditingAuthor ? (
<>
<button type="button" onClick={() => setIsEditingAuthor(false)} className={iconButtonClassName} aria-label="Cancel edit">
<CloseIcon size={18} />
</button>
<button type="button" onClick={saveAuthor} className={iconButtonClassName} aria-label="Confirm edit">
<CheckIcon size={18} />
</button>
</>
) : (
<button
type="button"
onClick={() => {
startEditing('author');
setIsEditingAuthor(true);
}}
className={iconButtonClassName}
aria-label="Edit author"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditingAuthor ? (
<div className="relative mt-1 flex gap-2">
<input type="text" value={editAuthor} onChange={e => setEditAuthor(e.target.value)} className={editInputClassName} />
</div>
) : (
<FieldValue>{document.author}</FieldValue>
)}
</Field>
<Field <Field
label={ label={
@@ -238,15 +376,11 @@ export default function DocumentPage() {
> >
<div className="flex text-xs"> <div className="flex text-xs">
<p className="w-32 text-content-subtle">Seconds / Percent</p> <p className="w-32 text-content-subtle">Seconds / Percent</p>
<p className="font-medium"> <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-content-subtle">Words / Minute</p> <p className="w-32 text-content-subtle">Words / Minute</p>
<p className="font-medium"> <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-content-subtle">Est. Time Left</p> <p className="w-32 text-content-subtle">Est. Time Left</p>
@@ -270,14 +404,52 @@ export default function DocumentPage() {
</Field> </Field>
</div> </div>
<EditableField <Field
label="Description" isEditing={isEditingDescription}
value={document.description || ''} label={
multiline <>
valueClassName="hyphens-auto text-justify" <FieldLabel>Description</FieldLabel>
onSave={value => save({ description: value })} <FieldActions>
{isEditingDescription ? (
<>
<button type="button" onClick={() => setIsEditingDescription(false)} className={iconButtonClassName} aria-label="Cancel edit">
<CloseIcon size={18} />
</button>
<button type="button" onClick={saveDescription} className={iconButtonClassName} aria-label="Confirm edit">
<CheckIcon size={18} />
</button>
</>
) : (
<button
type="button"
onClick={() => {
startEditing('description');
setIsEditingDescription(true);
}}
className={iconButtonClassName}
aria-label="Edit description"
>
<EditIcon size={18} />
</button>
)}
</FieldActions>
</>
}
>
{isEditingDescription ? (
<div className="relative mt-1 flex gap-2">
<textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
className="h-32 w-full grow rounded border border-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-none focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
rows={5}
/> />
</div> </div>
) : (
<FieldValue className="hyphens-auto text-justify">{document.description || 'N/A'}</FieldValue>
)}
</Field>
</div>
</div> </div>
); );
} }
+161 -114
View File
@@ -1,40 +1,134 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } 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 } from '../generated/model'; import type { Document, DocumentsResponse } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { LoadingState, Pagination, TextInput } from '../components'; import { LoadingState, Pagination } from '../components';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebounce } from '../hooks/useDebounce';
import { getErrorMessage } from '../utils/errors';
import { import {
getDocumentsViewMode, getDocumentsViewMode,
setDocumentsViewMode, setDocumentsViewMode,
type DocumentsViewMode, type DocumentsViewMode,
} from '../utils/localSettings'; } from '../utils/localSettings';
const DOCUMENTS_PAGE_SIZE = 9; interface DocumentCardProps {
interface DocumentItemProps {
doc: Document; doc: Document;
layout: 'grid' | 'list';
} }
function DocumentItem({ doc, layout }: DocumentItemProps) { function DocumentCard({ doc }: DocumentCardProps) {
const navigate = useNavigate(); 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;
const open = () => navigate(`/documents/${doc.id}`); return (
const onKeyDown = (event: React.KeyboardEvent) => { <div className="relative w-full">
<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 === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
open(); navigate(`/documents/${doc.id}`);
}
}}
>
<div className="relative my-auto h-48 min-w-fit">
<img
className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title}
/>
</div>
<div className="flex w-full flex-col justify-around text-sm text-content">
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Title</p>
<p className="font-medium">{doc.title || 'Unknown'}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Author</p>
<p className="font-medium">{doc.author || 'Unknown'}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Progress</p>
<p className="font-medium">{percentage}%</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">Time Read</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div>
</div>
</div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} />
</Link>
{doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
<DownloadIcon size={20} />
</a>
) : (
<DownloadIcon size={20} disabled />
)}
</div>
</div>
</div>
);
} }
};
const icons = ( 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"> <div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}> <Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} /> <ActivityIcon size={20} />
@@ -47,82 +141,21 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
<DownloadIcon size={20} disabled /> <DownloadIcon size={20} disabled />
)} )}
</div> </div>
);
const fields = [
{ label: 'Title', value: doc.title || 'Unknown' },
{ label: 'Author', value: doc.author || 'Unknown' },
{ label: 'Progress', value: `${percentage}%` },
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
];
if (layout === 'grid') {
return (
<div className="relative w-full">
<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-hidden"
onClick={open}
onKeyDown={onKeyDown}
>
<div className="relative my-auto h-48 min-w-fit">
<img
className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title}
/>
</div>
<div className="flex w-full flex-col justify-around text-sm text-content">
{fields.map(f => (
<div key={f.label} className="inline-flex shrink-0 items-center">
<div>
<p className="text-content-subtle">{f.label}</p>
<p className="font-medium">{f.value}</p>
</div>
</div>
))}
</div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
{icons}
</div>
</div>
</div>
);
}
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-hidden"
onClick={open}
onKeyDown={onKeyDown}
>
<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">
{fields.map(f => (
<div key={f.label}>
<p className="text-content-subtle">{f.label}</p>
<p className="font-medium">{f.value}</p>
</div>
))}
</div>
{icons}
</div> </div>
</div> </div>
); );
} }
export default function DocumentsPage() { export default function DocumentsPage() {
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const limit = DOCUMENTS_PAGE_SIZE; const [limit] = useState(9);
const [uploadMode, setUploadMode] = useState(false); const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode); const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showWarning } = useToasts(); const { showInfo, showWarning, showError } = useToasts();
const toastMutationOptions = useMutationWithToast();
const debouncedSearch = useDebounce(search, 300);
useEffect(() => { useEffect(() => {
setDocumentsViewMode(viewMode); setDocumentsViewMode(viewMode);
@@ -134,12 +167,12 @@ export default function DocumentsPage() {
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch }); const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument(); const createMutation = useCreateDocument();
const documentsResponse = data?.status === 200 ? data.data : undefined; const docs = (data?.data as DocumentsResponse | undefined)?.documents;
const docs = documentsResponse?.documents; const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
const previousPage = documentsResponse?.previous_page; const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
const nextPage = documentsResponse?.next_page;
const uploadDocument = (file: File | undefined) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (!file.name.endsWith('.epub')) { if (!file.name.endsWith('.epub')) {
@@ -147,17 +180,18 @@ export default function DocumentsPage() {
return; return;
} }
createMutation.mutate( try {
{ data: { document_file: file } }, await createMutation.mutateAsync({
toastMutationOptions({ data: {
success: 'Document uploaded successfully!', document_file: file,
error: 'Failed to upload document', },
onSuccess: () => { });
showInfo('Document uploaded successfully!');
setUploadMode(false); setUploadMode(false);
refetch(); refetch();
}, } catch (error) {
}) showError('Failed to upload document: ' + getErrorMessage(error));
); }
}; };
const handleCancelUpload = () => { const handleCancelUpload = () => {
@@ -180,13 +214,14 @@ export default function DocumentsPage() {
<div className="flex flex-col gap-4 lg:flex-row"> <div className="flex flex-col gap-4 lg:flex-row">
<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-border bg-surface px-3 text-sm text-content-muted shadow-xs"> <span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
<Search2Icon size={15} hoverable={false} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<TextInput <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-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"
/> />
@@ -216,7 +251,7 @@ export default function DocumentsPage() {
{isLoading ? ( {isLoading ? (
<LoadingState className="col-span-full min-h-48" /> <LoadingState className="col-span-full min-h-48" />
) : docs && docs.length > 0 ? ( ) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />) 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"> <div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found. No documents found.
@@ -228,7 +263,7 @@ export default function DocumentsPage() {
{isLoading ? ( {isLoading ? (
<LoadingState className="min-h-48" /> <LoadingState className="min-h-48" />
) : docs && docs.length > 0 ? ( ) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />) docs.map(doc => <DocumentListItem key={doc.id} doc={doc} />)
) : ( ) : (
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg"> <div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found. No documents found.
@@ -241,47 +276,59 @@ export default function DocumentsPage() {
page={page} page={page}
previousPage={previousPage} previousPage={previousPage}
nextPage={nextPage} nextPage={nextPage}
total={documentsResponse?.total} total={(data?.data as DocumentsResponse | undefined)?.total}
limit={limit} limit={limit}
onPageChange={setPage} onPageChange={setPage}
/> />
<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">
{uploadMode && ( <input
<div 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"> type="checkbox"
<div className="flex flex-col gap-2"> id="upload-file-button"
className="hidden"
checked={uploadMode}
onChange={() => setUploadMode(!uploadMode)}
/>
<div
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200 ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
>
<form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
<input <input
type="file" type="file"
accept=".epub" accept=".epub"
id="document_file" id="document_file"
name="document_file" name="document_file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange}
/> />
<button <button
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface" className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
type="button" type="submit"
onClick={() => uploadDocument(fileInputRef.current?.files?.[0])} onClick={e => {
e.preventDefault();
handleFileChange({
target: { files: fileInputRef.current?.files },
} as React.ChangeEvent<HTMLInputElement>);
}}
> >
Upload File Upload File
</button> </button>
</div> </form>
<button <label htmlFor="upload-file-button">
type="button" <div
className="mt-2 w-full cursor-pointer bg-surface-strong px-2 py-1 text-center font-medium text-content hover:bg-surface" 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
</button>
</div> </div>
)} </label>
<button </div>
type="button" <label
onClick={() => setUploadMode(!uploadMode)}
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100" className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100"
aria-label="Upload document" htmlFor="upload-file-button"
> >
<UploadIcon size={34} className="text-content-inverse" /> <UploadIcon size={34} className="text-content-inverse" />
</button> </label>
</div> </div>
</div> </div>
); );
+6 -6
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { LoadingState } from '../components';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetHome } from '../generated/anthoLumeAPIV1'; import { useGetHome } from '../generated/anthoLumeAPIV1';
import type { import type {
HomeResponse,
LeaderboardData, LeaderboardData,
LeaderboardEntry, LeaderboardEntry,
UserStreak, UserStreak,
@@ -154,7 +154,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
<div> <div>
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => ( {currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
<div <div
key={item.user_id} key={index}
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`} className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`}
> >
<div> <div>
@@ -172,14 +172,14 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
export default function HomePage() { export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome(); const { data: homeData, isLoading: homeLoading } = useGetHome();
const homeResponse = homeData?.status === 200 ? homeData.data : null; const homeResponse = homeData?.status === 200 ? (homeData.data as HomeResponse) : null;
const dbInfo = homeResponse?.database_info; const dbInfo = homeResponse?.database_info;
const streaks = homeResponse?.streaks?.streaks; const streaks = homeResponse?.streaks?.streaks;
const graphData = homeResponse?.graph_data?.graph_data; const graphData = homeResponse?.graph_data?.graph_data;
const userStats = homeResponse?.user_statistics; const userStats = homeResponse?.user_statistics;
if (homeLoading) { if (homeLoading) {
return <LoadingState />; return <div className="text-content-muted">Loading...</div>;
} }
return ( return (
@@ -201,9 +201,9 @@ export default function HomePage() {
</div> </div>
<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) => ( {streaks?.map((streak: UserStreak, index: number) => (
<StreakCard <StreakCard
key={streak.window} key={index}
window={streak.window as 'DAY' | 'WEEK'} window={streak.window as 'DAY' | 'WEEK'}
currentStreak={streak.current_streak} currentStreak={streak.current_streak}
currentStreakStartDate={streak.current_streak_start_date} currentStreakStartDate={streak.current_streak_start_date}
+94 -8
View File
@@ -1,9 +1,19 @@
import { useState, SyntheticEvent, useEffect } from 'react'; import { useState, FormEvent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
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';
import { AuthFormView, authFormFooter } from './AuthFormView';
interface LoginPageViewProps {
username: string;
password: string;
isLoading: boolean;
registrationEnabled: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<void>;
}
export function getRegistrationEnabled(infoData: unknown): boolean { export function getRegistrationEnabled(infoData: unknown): boolean {
if (!infoData || typeof infoData !== 'object') { if (!infoData || typeof infoData !== 'object') {
@@ -24,6 +34,84 @@ export function getRegistrationEnabled(infoData: unknown): boolean {
return infoData.data.registration_enabled; return infoData.data.registration_enabled;
} }
export function LoginPageView({
username,
password,
isLoading,
registrationEnabled,
onUsernameChange,
onPasswordChange,
onSubmit,
}: LoginPageViewProps) {
return (
<div className="min-h-screen bg-canvas text-content">
<div className="flex w-full flex-wrap">
<div className="flex w-full flex-col md:w-1/2">
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
<p className="text-center text-3xl">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
<div className="flex flex-col pt-4">
<div className="relative flex">
<input
type="text"
value={username}
onChange={e => onUsernameChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Username"
required
disabled={isLoading}
/>
</div>
</div>
<div className="mb-12 flex flex-col pt-4">
<div className="relative flex">
<input
type="password"
value={password}
onChange={e => onPasswordChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Password"
required
disabled={isLoading}
/>
</div>
</div>
<Button
variant="secondary"
type="submit"
disabled={isLoading}
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-none focus:ring-2 disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
<div className="py-12 text-center">
{registrationEnabled && (
<p>
Don&apos;t have an account?{' '}
<Link to="/register" className="font-semibold underline">
Register here.
</Link>
</p>
)}
<p className={registrationEnabled ? 'mt-4' : ''}>
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</div>
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
<span className="text-content-muted">AnthoLume</span>
</div>
</div>
</div>
</div>
);
}
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -46,7 +134,7 @@ export default function LoginPage() {
} }
}, [isAuthenticated, isCheckingAuth, navigate]); }, [isAuthenticated, isCheckingAuth, navigate]);
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
@@ -60,16 +148,14 @@ export default function LoginPage() {
}; };
return ( return (
<AuthFormView <LoginPageView
username={username} username={username}
password={password} password={password}
isLoading={isLoading} isLoading={isLoading}
registrationEnabled={registrationEnabled}
onUsernameChange={setUsername} onUsernameChange={setUsername}
onPasswordChange={setPassword} onPasswordChange={setPassword}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitLabel="Login"
submittingLabel="Logging in..."
footer={authFormFooter({ to: '/register', text: 'Register here.' }, registrationEnabled)}
/> />
); );
} }
+10 -11
View File
@@ -5,39 +5,38 @@ import type { Progress } from '../generated/model';
import { Pagination } from '../components'; import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
const PROGRESS_PAGE_SIZE = 15;
export default function ProgressPage() { export default function ProgressPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const limit = PROGRESS_PAGE_SIZE; const limit = 15;
const { data, isLoading } = useGetProgressList({ page, limit }); const { data, isLoading } = useGetProgressList({ page, limit });
const response = data?.status === 200 ? data.data : undefined; const response = data?.status === 200 ? data.data : undefined;
const progress = response?.progress ?? []; const progress = response?.progress ?? [];
const columns: Column<Progress>[] = [ const columns: Column<Progress>[] = [
{ {
id: 'document', key: 'document_id' as const,
header: 'Document', header: 'Document',
render: row => ( render: (_value, row) => (
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline"> <Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
{row.author || 'Unknown'} - {row.title || 'Unknown'} {row.author || 'Unknown'} - {row.title || 'Unknown'}
</Link> </Link>
), ),
}, },
{ {
id: 'device_name', key: 'device_name' as const,
header: 'Device Name', header: 'Device Name',
render: row => row.device_name || 'Unknown', render: value => String(value || 'Unknown'),
}, },
{ {
id: 'percentage', key: 'percentage' as const,
header: 'Percentage', header: 'Percentage',
render: row => (typeof row.percentage === 'number' ? `${Math.round(row.percentage)}%` : '0%'), render: value => (typeof value === 'number' ? `${Math.round(value)}%` : '0%'),
}, },
{ {
id: 'created_at', key: 'created_at' as const,
header: 'Created At', header: 'Created At',
render: row => (row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'), render: value =>
typeof value === 'string' && value ? new Date(value).toLocaleDateString() : 'N/A',
}, },
]; ];
+7 -8
View File
@@ -74,10 +74,7 @@ export default function ReaderPage() {
colorScheme, colorScheme,
fontFamily, fontFamily,
fontSize, fontSize,
isPaginationDisabled: useCallback( isPaginationDisabled: useCallback(() => isTopBarOpen || isBottomBarOpen, [isTopBarOpen, isBottomBarOpen]),
() => isTopBarOpen || isBottomBarOpen,
[isTopBarOpen, isBottomBarOpen]
),
onSwipeDown: handleSwipeDown, onSwipeDown: handleSwipeDown,
onSwipeUp: handleSwipeUp, onSwipeUp: handleSwipeUp,
onCenterTap: handleCenterTap, onCenterTap: handleCenterTap,
@@ -89,6 +86,10 @@ export default function ReaderPage() {
} }
}, [document?.title]); }, [document?.title]);
useEffect(() => {
reader.setTheme({ colorScheme, fontFamily, fontSize });
}, [colorScheme, fontFamily, fontSize, reader.setTheme]);
useEffect(() => { useEffect(() => {
if (isTopBarOpen || isBottomBarOpen) { if (isTopBarOpen || isBottomBarOpen) {
return; return;
@@ -121,7 +122,7 @@ export default function ReaderPage() {
<div className="flex min-w-0 items-start gap-4"> <div className="flex min-w-0 items-start gap-4">
<Link to={`/documents/${document.id}`} className="block shrink-0"> <Link to={`/documents/${document.id}`} className="block shrink-0">
<img <img
className="h-28 w-20 rounded object-cover shadow-sm" className="h-28 w-20 rounded object-cover shadow"
src={`/api/v1/documents/${document.id}/cover`} src={`/api/v1/documents/${document.id}/cover`}
alt={`${document.title} cover`} alt={`${document.title} cover`}
/> />
@@ -215,9 +216,7 @@ export default function ReaderPage() {
<div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto] lg:items-start"> <div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto] lg:items-start">
<div className="min-w-0"> <div className="min-w-0">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle"> <p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Theme</p>
Theme
</p>
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
{colorSchemes.map(option => ( {colorSchemes.map(option => (
<button <button
+70 -17
View File
@@ -1,10 +1,9 @@
import { useState, SyntheticEvent, useEffect } from 'react'; import { useState, FormEvent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
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';
import { AuthFormView, authFormFooter } from './AuthFormView';
import { getRegistrationEnabled } from './LoginPage';
export default function RegisterPage() { export default function RegisterPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -20,7 +19,10 @@ export default function RegisterPage() {
}, },
}); });
const registrationEnabled = getRegistrationEnabled(infoData); const registrationEnabled =
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
? infoData.data.registration_enabled
: false;
useEffect(() => { useEffect(() => {
if (!isCheckingAuth && isAuthenticated) { if (!isCheckingAuth && isAuthenticated) {
@@ -33,7 +35,7 @@ export default function RegisterPage() {
} }
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]); }, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
@@ -47,17 +49,68 @@ export default function RegisterPage() {
}; };
return ( return (
<AuthFormView <div className="min-h-screen bg-canvas text-content">
username={username} <div className="flex w-full flex-wrap">
password={password} <div className="flex w-full flex-col md:w-1/2">
isLoading={isLoading} <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">
onUsernameChange={setUsername} <p className="text-center text-3xl">Welcome.</p>
onPasswordChange={setPassword} <form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
onSubmit={handleSubmit} <div className="flex flex-col pt-4">
submitLabel="Register" <div className="relative flex">
submittingLabel="Registering..." <input
inputsDisabled={isLoadingInfo || !registrationEnabled} type="text"
footer={authFormFooter({ to: '/login', text: 'Login here.' }, true)} value={username}
onChange={e => setUsername(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Username"
required
disabled={isLoading || isLoadingInfo || !registrationEnabled}
/> />
</div>
</div>
<div className="mb-12 flex flex-col pt-4">
<div className="relative flex">
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Password"
required
disabled={isLoading || isLoadingInfo || !registrationEnabled}
/>
</div>
</div>
<Button
variant="secondary"
type="submit"
disabled={isLoading || isLoadingInfo || !registrationEnabled}
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-none focus:ring-2 disabled:opacity-50"
>
{isLoading ? 'Registering...' : 'Register'}
</Button>
</form>
<div className="py-12 text-center">
<p>
Trying to login?{' '}
<Link to="/login" className="font-semibold underline">
Login here.
</Link>
</p>
<p className="mt-4">
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</div>
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
<span className="text-content-muted">AnthoLume</span>
</div>
</div>
</div>
</div>
); );
} }
+2 -5
View File
@@ -50,12 +50,9 @@ describe('SearchPage', () => {
isLoading: true, isLoading: true,
} as ReturnType<typeof useGetSearch>); } as ReturnType<typeof useGetSearch>);
const { container } = render(<SearchPage />); render(<SearchPage />);
// Loading renders the Table skeleton (animated placeholder rows), and the search form stays mounted. expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
expect(screen.getByPlaceholderText('Query')).toBeInTheDocument();
expect(screen.queryByText('No Results')).not.toBeInTheDocument();
}); });
it('shows an empty state when there are no results', () => { it('shows an empty state when there are no results', () => {
+86 -38
View File
@@ -1,37 +1,12 @@
import { useState, SyntheticEvent } 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 { Button } from '../components/Button'; import { Button } from '../components/Button';
import { TextInput } from '../components'; import { LoadingState } from '../components';
import { inputClassName } from '../components/TextInput'; import { useDebounce } from '../hooks/useDebounce';
import { Table, type Column } from '../components/Table';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon, DownloadIcon, BookIcon } from '../icons'; import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
const searchColumns: Column<SearchItem>[] = [
{
id: 'download',
header: '',
className: 'w-12 text-content-muted',
render: () => (
<button className="hover:text-primary-600" title="Download">
<DownloadIcon size={15} />
</button>
),
},
{ id: 'document', header: 'Document', render: item => `${item.author || 'N/A'} - ${item.title || 'N/A'}` },
{ id: 'series', header: 'Series', render: item => item.series || 'N/A' },
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
{
id: 'date',
header: 'Date',
className: 'hidden md:table-cell',
render: item => item.upload_date || 'N/A',
},
];
interface SearchPageViewProps { interface SearchPageViewProps {
query: string; query: string;
source: GetSearchSource; source: GetSearchSource;
@@ -39,7 +14,7 @@ interface SearchPageViewProps {
results: SearchItem[]; results: SearchItem[];
onQueryChange: (value: string) => void; onQueryChange: (value: string) => void;
onSourceChange: (value: GetSearchSource) => void; onSourceChange: (value: GetSearchSource) => void;
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void; onSubmit: (e: FormEvent<HTMLFormElement>) => void;
} }
export function getSearchResults(data: unknown): SearchItem[] { export function getSearchResults(data: unknown): SearchItem[] {
@@ -78,45 +53,118 @@ export function SearchPageView({
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}> <form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
<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-border bg-surface px-3 text-sm text-content-muted shadow-xs"> <span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
<Search2Icon size={15} hoverable={false} /> <Search2Icon size={15} hoverable={false} />
</span> </span>
<TextInput <input
type="text" type="text"
value={query} value={query}
onChange={e => onQueryChange(e.target.value)} onChange={e => onQueryChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Query" 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-border bg-surface px-3 text-sm text-content-muted shadow-xs"> <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 => onSourceChange(e.target.value as GetSearchSource)} onChange={e => onSourceChange(e.target.value as GetSearchSource)}
className={inputClassName} 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={GetSearchSource.LibGen}>Library Genesis</option> <option value={GetSearchSource.LibGen}>Library Genesis</option>
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option> <option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
</select> </select>
</div> </div>
<Button variant="secondary" type="submit" className="w-full lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">
Search Search
</Button> </Button>
</div>
</form> </form>
</div> </div>
<Table columns={searchColumns} data={results} loading={isLoading} rowKey="id" /> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-surface text-sm leading-normal text-content md:text-sm">
<thead className="text-content-muted">
<tr>
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase"></th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Document
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Series
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Type
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Size
</th>
<th className="hidden border-b border-border p-3 text-left font-normal uppercase md:table-cell">
Date
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="p-3 text-center" colSpan={6}>
<LoadingState />
</td>
</tr>
)}
{!isLoading && results.length === 0 && (
<tr>
<td className="p-3 text-center" colSpan={6}>
No Results
</td>
</tr>
)}
{!isLoading &&
results.map(item => (
<tr key={item.id}>
<td className="border-b border-border p-3 text-content-muted">
<button className="hover:text-primary-600" title="Download">
<DownloadIcon size={15} />
</button>
</td>
<td className="border-b border-border p-3">
{item.author || 'N/A'} - {item.title || 'N/A'}
</td>
<td className="border-b border-border p-3">
<p>{item.series || 'N/A'}</p>
</td>
<td className="border-b border-border p-3">
<p>{item.file_type || 'N/A'}</p>
</td>
<td className="border-b border-border p-3">
<p>{item.file_size || 'N/A'}</p>
</td>
<td className="hidden border-b border-border p-3 md:table-cell">
<p>{item.upload_date || 'N/A'}</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
); );
} }
export default function SearchPage() { export default function SearchPage() {
const [query, setQuery, activeQuery, flushQuery] = useDebouncedState('', 300); const [query, setQuery] = useState('');
const [activeQuery, setActiveQuery] = useState('');
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen); const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
setActiveQuery(debouncedQuery);
}, [debouncedQuery]);
const { data, isLoading } = useGetSearch( const { data, isLoading } = useGetSearch(
{ query: activeQuery, source }, { query: activeQuery, source },
@@ -128,9 +176,9 @@ export default function SearchPage() {
); );
const results = getSearchResults(data); const results = getSearchResults(data);
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => { const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
flushQuery(query.trim()); setActiveQuery(query.trim());
}; };
return ( return (
+140 -50
View File
@@ -1,29 +1,13 @@
import { useState, useEffect, SyntheticEvent } from 'react'; import { useState, useEffect, FormEvent } from 'react';
import { LoadingState, TextInput } from '../components';
import { inputClassName } from '../components/TextInput';
import { Table, type Column } from '../components/Table';
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1'; import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
import type { Device } from '../generated/model'; import type { Device, SettingsResponse } from '../generated/model';
import { UserIcon, PasswordIcon, ClockIcon } from '../icons'; 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 { useMutationWithToast } from '../hooks/useMutationWithToast'; import { getErrorMessage } from '../utils/errors';
import { useTheme } from '../theme/ThemeProvider'; import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings'; import type { ThemeMode } from '../utils/localSettings';
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
const deviceColumns: Column<Device>[] = [
{
id: 'name',
header: 'Name',
className: 'pl-0',
render: device => device.device_name || 'Unknown',
},
{ id: 'last_synced', header: 'Last Sync', render: device => formatDeviceDate(device.last_synced) },
{ id: 'created_at', header: 'Created', render: device => formatDeviceDate(device.created_at) },
];
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [ const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
{ value: 'light', label: 'Light', description: 'Always use the light palette.' }, { value: 'light', label: 'Light', description: 'Always use the light palette.' },
{ value: 'dark', label: 'Dark', description: 'Always use the dark palette.' }, { value: 'dark', label: 'Dark', description: 'Always use the dark palette.' },
@@ -33,9 +17,8 @@ const themeModes: Array<{ value: ThemeMode; label: string; description: string }
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 : null; const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
const { showError } = useToasts(); const { showInfo, showError } = useToasts();
const toastMutationOptions = useMutationWithToast();
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme(); const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -48,7 +31,7 @@ export default function SettingsPage() {
} }
}, [settingsData]); }, [settingsData]);
const handlePasswordSubmit = (e: SyntheticEvent) => { const handlePasswordSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!password || !newPassword) { if (!password || !newPassword) {
@@ -56,33 +39,93 @@ export default function SettingsPage() {
return; return;
} }
updateSettings.mutate( try {
{ data: { password, new_password: newPassword } }, const response = await updateSettings.mutateAsync({
toastMutationOptions({ data: {
success: 'Password updated successfully', password,
error: 'Failed to update password', new_password: newPassword,
onSuccess: () => { },
});
if (response.status >= 200 && response.status < 300) {
showInfo('Password updated successfully');
setPassword(''); setPassword('');
setNewPassword(''); setNewPassword('');
}, return;
}) }
);
showError('Failed to update password: ' + getErrorMessage(response.data));
} catch (error) {
showError('Failed to update password: ' + getErrorMessage(error));
}
}; };
const handleTimezoneSubmit = (e: SyntheticEvent) => { const handleTimezoneSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
updateSettings.mutate( try {
{ data: { timezone } }, const response = await updateSettings.mutateAsync({
toastMutationOptions({ data: {
success: 'Timezone updated successfully', timezone,
error: 'Failed to update timezone', },
}) });
);
if (response.status >= 200 && response.status < 300) {
showInfo('Timezone updated successfully');
return;
}
showError('Failed to update timezone: ' + getErrorMessage(response.data));
} catch (error) {
showError('Failed to update timezone: ' + getErrorMessage(error));
}
}; };
if (isLoading) { if (isLoading) {
return <LoadingState />; return (
<div className="flex w-full flex-col gap-4 md:flex-row">
<div>
<div className="flex flex-col items-center rounded bg-surface p-4 shadow-lg md:w-60 lg:w-80">
<div className="mb-4 size-16 rounded-full bg-surface-strong" />
<div className="h-6 w-32 rounded bg-surface-strong" />
</div>
</div>
<div className="flex grow flex-col gap-4">
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="flex gap-4">
<div className="h-12 flex-1 rounded bg-surface-strong" />
<div className="h-12 flex-1 rounded bg-surface-strong" />
<div className="h-10 w-40 rounded bg-surface-strong" />
</div>
</div>
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="flex gap-4">
<div className="h-12 flex-1 rounded bg-surface-strong" />
<div className="h-10 w-40 rounded bg-surface-strong" />
</div>
</div>
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
<div className="grid gap-3 md:grid-cols-3">
{themeModes.map(mode => (
<div key={mode.value} className="h-24 rounded bg-surface-strong" />
))}
</div>
</div>
<div className="flex flex-col rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-24 rounded bg-surface-strong" />
<div className="mb-4 flex gap-4">
<div className="h-6 flex-1 rounded bg-surface-strong" />
<div className="h-6 flex-1 rounded bg-surface-strong" />
<div className="h-6 flex-1 rounded bg-surface-strong" />
</div>
<div className="h-32 flex-1 rounded bg-surface-strong" />
</div>
</div>
</div>
);
} }
return ( return (
@@ -100,33 +143,37 @@ export default function SettingsPage() {
<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-border bg-surface px-3 text-sm text-content-muted shadow-xs"> <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>
<TextInput <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-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-border bg-surface px-3 text-sm text-content-muted shadow-xs"> <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>
<TextInput <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-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>
</div> </div>
<Button variant="secondary" type="submit" className="w-full lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">
Submit Submit
</Button> </Button>
</div>
</form> </form>
</div> </div>
@@ -173,13 +220,13 @@ export default function SettingsPage() {
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p> <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-border bg-surface px-3 text-sm text-content-muted shadow-xs"> <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={inputClassName} 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>
@@ -193,15 +240,58 @@ export default function SettingsPage() {
<option value="Australia/Sydney">Australia/Sydney</option> <option value="Australia/Sydney">Australia/Sydney</option>
</select> </select>
</div> </div>
<Button variant="secondary" type="submit" className="w-full lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">
Submit Submit
</Button> </Button>
</div>
</form> </form>
</div> </div>
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg"> <div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="text-lg font-semibold text-content">Devices</p> <p className="text-lg font-semibold text-content">Devices</p>
<Table columns={deviceColumns} data={settingsData?.devices ?? []} rowKey="id" /> <table className="min-w-full bg-surface text-sm">
<thead className="text-content-muted">
<tr>
<th className="border-b border-border p-3 pl-0 text-left font-normal uppercase">
Name
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Last Sync
</th>
<th className="border-b border-border p-3 text-left font-normal uppercase">
Created
</th>
</tr>
</thead>
<tbody className="text-content">
{!settingsData?.devices || settingsData.devices.length === 0 ? (
<tr>
<td className="p-3 text-center" colSpan={3}>
No Results
</td>
</tr>
) : (
settingsData.devices.map((device: Device) => (
<tr key={device.id}>
<td className="p-3 pl-0">
<p>{device.device_name || 'Unknown'}</p>
</td>
<td className="p-3">
<p>
{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}
</p>
</td>
<td className="p-3">
<p>
{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}
</p>
</td>
</tr>
))
)}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
+11 -12
View File
@@ -7,12 +7,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { import { getThemeMode, setThemeMode, type ThemeMode } from '../utils/localSettings';
getThemeMode,
setThemeMode,
LOCAL_SETTINGS_KEY,
type ThemeMode,
} from '../utils/localSettings';
export type ResolvedThemeMode = 'light' | 'dark'; export type ResolvedThemeMode = 'light' | 'dark';
@@ -58,20 +53,21 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
resolveThemeMode(getThemeMode()) resolveThemeMode(getThemeMode())
); );
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just sets `themeModeState`.
useEffect(() => { useEffect(() => {
setResolvedThemeMode(applyThemeMode(themeModeState)); setResolvedThemeMode(applyThemeMode(themeModeState));
}, [themeModeState]); }, [themeModeState]);
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeModeState` is unchanged.
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || themeModeState !== 'system') { if (typeof window === 'undefined') {
return undefined; return undefined;
} }
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => { const handleSystemThemeChange = () => {
if (themeModeState === 'system') {
setResolvedThemeMode(applyThemeMode('system')); setResolvedThemeMode(applyThemeMode('system'));
}
}; };
mediaQueryList.addEventListener('change', handleSystemThemeChange); mediaQueryList.addEventListener('change', handleSystemThemeChange);
@@ -80,17 +76,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}; };
}, [themeModeState]); }, [themeModeState]);
// Cross-Tab Sync - Another tab changed the persisted theme; adopt its mode and let the mode effect apply it.
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return undefined; return undefined;
} }
const handleStorage = (event: StorageEvent) => { const handleStorage = (event: StorageEvent) => {
if (event.key && event.key !== LOCAL_SETTINGS_KEY) { if (event.key && event.key !== 'antholume:settings') {
return; return;
} }
setThemeModeState(getThemeMode());
const nextThemeMode = getThemeMode();
setThemeModeState(nextThemeMode);
setResolvedThemeMode(applyThemeMode(nextThemeMode));
}; };
window.addEventListener('storage', handleStorage); window.addEventListener('storage', handleStorage);
@@ -102,6 +100,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => { const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
setThemeMode(nextThemeMode); setThemeMode(nextThemeMode);
setThemeModeState(nextThemeMode); setThemeModeState(nextThemeMode);
setResolvedThemeMode(applyThemeMode(nextThemeMode));
}, []); }, []);
const value = useMemo( const value = useMemo(
-57
View File
@@ -1,57 +0,0 @@
interface StreamToFileOptions {
suggestedName: string;
mimeType: string;
extension: string;
}
/**
* Streams a response body to a user-chosen file. Uses the File System Access API when
* available (large-file friendly, no memory buffering); otherwise falls back to a
* blob/object-URL download. Returns whether the download completed (false if the user
* cancelled or the browser lacks any download capability).
*/
export async function streamResponseToFile(
response: Response,
{ suggestedName, mimeType, extension }: StreamToFileOptions
): Promise<boolean> {
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
try {
const handle = await window.showSaveFilePicker({
suggestedName,
types: [{ description: 'File', accept: { [mimeType]: [extension] } }],
});
const writable = await handle.createWritable();
const reader = response.body?.getReader();
if (!reader) throw new Error('Unable to read response');
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
return true;
} catch (err) {
// User-cancelled picker resolves as AbortError; treat as "no download".
if ((err as Error).name === 'AbortError') return false;
throw err;
}
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
return true;
}
export function backupFilename(): string {
return `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
}
+31 -2
View File
@@ -6,8 +6,25 @@ describe('getErrorMessage', () => {
expect(getErrorMessage(new Error('Boom'))).toBe('Boom'); expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
}); });
it('reads the top-level message from API error bodies', () => { it('prefers response.data.message over top-level message', () => {
expect(getErrorMessage({ code: 401, message: 'Unauthorized' })).toBe('Unauthorized'); 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', () => { it('uses the fallback for null, empty, and unknown values', () => {
@@ -15,5 +32,17 @@ describe('getErrorMessage', () => {
expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message'); expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message');
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message'); expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message'); expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message');
expect(
getErrorMessage(
{
response: {
data: {
message: '',
},
},
},
'Fallback message'
)
).toBe('Fallback message');
}); });
}); });
+17 -4
View File
@@ -3,10 +3,23 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
return error.message; return error.message;
} }
if (typeof error === 'object' && error !== null && 'message' in error) { if (typeof error === 'object' && error !== null) {
const { message } = error as { message?: unknown }; const errorWithResponse = error as {
if (typeof message === 'string' && message.trim() !== '') { message?: unknown;
return message; response?: {
data?: {
message?: unknown;
};
};
};
const responseMessage = errorWithResponse.response?.data?.message;
if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {
return responseMessage;
}
if (typeof errorWithResponse.message === 'string' && errorWithResponse.message.trim() !== '') {
return errorWithResponse.message;
} }
} }
-8
View File
@@ -70,11 +70,3 @@ export function formatDuration(seconds: number): string {
return parts.join(' '); return parts.join(' ');
} }
export function formatUtcDate(dateString: string): string {
const date = new Date(dateString);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
+1 -1
View File
@@ -3,7 +3,7 @@ export type DocumentsViewMode = 'grid' | 'list';
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black'; export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato'; export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
export const LOCAL_SETTINGS_KEY = 'antholume:settings'; const LOCAL_SETTINGS_KEY = 'antholume:settings';
interface LocalSettings { interface LocalSettings {
themeMode?: ThemeMode; themeMode?: ThemeMode;
+51
View File
@@ -0,0 +1,51 @@
const withOpacity = cssVariable => `rgb(var(${cssVariable}) / <alpha-value>)`;
const buildScale = scaleName => ({
50: withOpacity(`--${scaleName}-50`),
100: withOpacity(`--${scaleName}-100`),
200: withOpacity(`--${scaleName}-200`),
300: withOpacity(`--${scaleName}-300`),
400: withOpacity(`--${scaleName}-400`),
500: withOpacity(`--${scaleName}-500`),
600: withOpacity(`--${scaleName}-600`),
700: withOpacity(`--${scaleName}-700`),
800: withOpacity(`--${scaleName}-800`),
900: withOpacity(`--${scaleName}-900`),
DEFAULT: withOpacity(`--${scaleName}-500`),
foreground: withOpacity(`--${scaleName}-foreground`),
});
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
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: [],
};
+1 -2
View File
@@ -1,9 +1,8 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react()],
server: { server: {
allowedHosts: ['lin-va-terminal'], allowedHosts: ['lin-va-terminal'],
proxy: { proxy: {