Compare commits
5 Commits
0930054847
...
evan/api-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 64aa000323 | |||
| a865457bbf | |||
| a950d50440 | |||
| 00faf9cea8 | |||
| 75c872264f |
17
.air.toml
Normal file
17
.air.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
root = "."
|
||||
tmp_dir = "_scratch/air"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./_scratch/air/antholume ."
|
||||
full_bin = "./_scratch/air/antholume serve"
|
||||
delay = 1000
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "css", "js", "yaml", "yml"]
|
||||
exclude_dir = ["_scratch", "build", "data", "frontend", "node_modules"]
|
||||
exclude_file = ["assets/style.css"]
|
||||
stop_on_error = true
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@ TODO.md
|
||||
.DS_Store
|
||||
data/
|
||||
build/
|
||||
tmp/
|
||||
.direnv/
|
||||
cover.html
|
||||
node_modules
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -41,16 +41,20 @@ Regenerate:
|
||||
## 4) Backend / Assets
|
||||
|
||||
### Common commands
|
||||
- Dev server: `make dev`
|
||||
- Direct dev run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve`
|
||||
- Full dev stack: `make dev` (backend on `:8585` + Vite frontend on `:5173`)
|
||||
- Backend only: `make dev_backend` (live-reloads with Air)
|
||||
- Frontend only: `make dev_frontend`
|
||||
- Direct backend run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve`
|
||||
- No-auth dev run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true DISABLE_AUTH=true DISABLE_AUTH_USER=evan go run main.go serve`
|
||||
- Tests: `make tests`
|
||||
- Tailwind asset build: `make build_tailwind`
|
||||
- Legacy server-rendered Tailwind asset build: `make legacy_tailwind`
|
||||
|
||||
### Notes
|
||||
- The Go server embeds `templates/*` and `assets/*`.
|
||||
- Root Tailwind output is built to `assets/style.css`.
|
||||
- Be mindful of whether a change affects the embedded server-rendered app, the React frontend, or both.
|
||||
- SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts.
|
||||
- `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.
|
||||
|
||||
## 5) Frontend
|
||||
|
||||
@@ -63,7 +67,24 @@ For frontend-specific implementation notes and commands, also read:
|
||||
- Frontend API client: `cd frontend && bun run generate:api`
|
||||
- SQLC: `sqlc generate`
|
||||
|
||||
## 7) Updating This File
|
||||
## 7) Live Dev Server Debugging
|
||||
|
||||
- The Vite dev server runs on `localhost:5173` and proxies `/api` to the Go backend on `localhost:8585`.
|
||||
- Use `glimpse` to interact with the running frontend for visual debugging:
|
||||
```bash
|
||||
# Snapshot rendered page state (text, links, forms, buttons)
|
||||
glimpse snapshot http://localhost:5173/some-page --wait-until=complete --timeout=15000
|
||||
|
||||
# Screenshot for visual inspection
|
||||
glimpse screenshot http://localhost:5173/some-page --wait-until=complete --output=_scratch/page.png
|
||||
|
||||
# Execute JS in the browser context (e.g. fill forms, click buttons, read state)
|
||||
glimpse exec http://localhost:5173/some-page --wait-until=complete --timeout=20000 --js='return document.title'
|
||||
```
|
||||
- Use `curl` for direct API testing (both `localhost:5173` via Vite proxy and `localhost:8585` directly work).
|
||||
- **Caveat:** Monkey-patching `window.fetch` inside `glimpse exec` breaks in Firefox with `TypeError: 'fetch' called on an object that does not implement interface Window.`. Avoid fetch interception; instead test API calls separately with `curl`.
|
||||
|
||||
## 8) Updating This File
|
||||
|
||||
After completing a task, update this `AGENTS.md` if you learned something general that would help future agents.
|
||||
|
||||
|
||||
40
Makefile
40
Makefile
@@ -1,4 +1,15 @@
|
||||
build_local: build_tailwind
|
||||
.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 \
|
||||
CONFIG_PATH=./data \
|
||||
DATA_PATH=./data \
|
||||
SEARCH_ENABLED=true \
|
||||
REGISTRATION_ENABLED=true \
|
||||
COOKIE_SECURE=false \
|
||||
COOKIE_AUTH_KEY=1234 \
|
||||
LOG_LEVEL=debug
|
||||
|
||||
build_local: legacy_tailwind
|
||||
go mod download
|
||||
rm -r ./build || true
|
||||
mkdir -p ./build
|
||||
@@ -8,17 +19,17 @@ build_local: build_tailwind
|
||||
env GOOS=darwin GOARCH=arm64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_darwin_arm64
|
||||
env GOOS=darwin GOARCH=amd64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_darwin_amd64
|
||||
|
||||
docker_build_local: build_tailwind
|
||||
docker_build_local: legacy_tailwind
|
||||
docker build -t antholume:latest .
|
||||
|
||||
docker_build_release_dev: build_tailwind
|
||||
docker_build_release_dev: legacy_tailwind
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t gitea.va.reichard.io/evan/antholume:dev \
|
||||
-f Dockerfile-BuildKit \
|
||||
--push .
|
||||
|
||||
docker_build_release_latest: build_tailwind
|
||||
docker_build_release_latest: legacy_tailwind
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t gitea.va.reichard.io/evan/antholume:latest \
|
||||
@@ -26,18 +37,19 @@ docker_build_release_latest: build_tailwind
|
||||
-f Dockerfile-BuildKit \
|
||||
--push .
|
||||
|
||||
build_tailwind:
|
||||
build_tailwind: legacy_tailwind
|
||||
|
||||
legacy_tailwind:
|
||||
tailwindcss build -o ./assets/style.css --minify
|
||||
|
||||
dev: build_tailwind
|
||||
GIN_MODE=release \
|
||||
CONFIG_PATH=./data \
|
||||
DATA_PATH=./data \
|
||||
SEARCH_ENABLED=true \
|
||||
REGISTRATION_ENABLED=true \
|
||||
COOKIE_SECURE=false \
|
||||
COOKIE_AUTH_KEY=1234 \
|
||||
LOG_LEVEL=debug go run main.go serve
|
||||
dev:
|
||||
$(MAKE) -j2 dev_backend dev_frontend
|
||||
|
||||
dev_backend:
|
||||
$(DEV_ENV) air
|
||||
|
||||
dev_frontend:
|
||||
cd frontend && bun run dev
|
||||
|
||||
clean:
|
||||
rm -rf ./build
|
||||
|
||||
11
README.md
11
README.md
@@ -134,7 +134,12 @@ go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
Run Development:
|
||||
|
||||
```bash
|
||||
CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve
|
||||
# Backend API/server on :8585 plus Vite frontend on :5173
|
||||
make dev
|
||||
|
||||
# Or run one side only
|
||||
make dev_backend # live-reloads with air
|
||||
make dev_frontend
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -152,8 +157,8 @@ make docker_build_local
|
||||
make docker_build_release_latest
|
||||
make docker_build_release_dev
|
||||
|
||||
# Generate Tailwind CSS
|
||||
make build_tailwind
|
||||
# Generate legacy Tailwind CSS for server-rendered templates
|
||||
make legacy_tailwind
|
||||
|
||||
# Clean Local Build
|
||||
make clean
|
||||
|
||||
58
api/auth.go
58
api/auth.go
@@ -49,7 +49,45 @@ func (api *API) authorizeCredentials(ctx context.Context, username string, passw
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDevAuth returns an authData for the dev user when DISABLE_AUTH is
|
||||
// set. If DISABLE_AUTH_USER names a specific user, that user is looked up;
|
||||
// otherwise the first user in the database is used.
|
||||
func (api *API) resolveDevAuth(c *gin.Context) (authData, bool) {
|
||||
if api.cfg.DisableAuthUser != "" {
|
||||
user, err := api.db.Queries.GetUser(c, api.cfg.DisableAuthUser)
|
||||
if err != nil {
|
||||
log.Errorf("DISABLE_AUTH_USER=%q not found in database: %v", api.cfg.DisableAuthUser, err)
|
||||
return authData{}, false
|
||||
}
|
||||
return authData{
|
||||
UserName: user.ID,
|
||||
IsAdmin: user.Admin,
|
||||
AuthHash: *user.AuthHash,
|
||||
}, true
|
||||
}
|
||||
|
||||
users, err := api.db.Queries.GetUsers(c)
|
||||
if err != nil || len(users) == 0 {
|
||||
return authData{}, false
|
||||
}
|
||||
return authData{
|
||||
UserName: users[0].ID,
|
||||
IsAdmin: users[0].Admin,
|
||||
AuthHash: *users[0].AuthHash,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (api *API) authKOMiddleware(c *gin.Context) {
|
||||
// Dev Auth Bypass
|
||||
if api.cfg.DisableAuth {
|
||||
if auth, ok := api.resolveDevAuth(c); ok {
|
||||
c.Set("Authorization", auth)
|
||||
c.Header("Cache-Control", "private")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
|
||||
// Check Session First
|
||||
@@ -89,6 +127,16 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (api *API) authOPDSMiddleware(c *gin.Context) {
|
||||
// Dev Auth Bypass
|
||||
if api.cfg.DisableAuth {
|
||||
if auth, ok := api.resolveDevAuth(c); ok {
|
||||
c.Set("Authorization", auth)
|
||||
c.Header("Cache-Control", "private")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Header("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
|
||||
user, rawPassword, hasAuth := c.Request.BasicAuth()
|
||||
@@ -113,6 +161,16 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (api *API) authWebAppMiddleware(c *gin.Context) {
|
||||
// Dev Auth Bypass
|
||||
if api.cfg.DisableAuth {
|
||||
if auth, ok := api.resolveDevAuth(c); ok {
|
||||
c.Set("Authorization", auth)
|
||||
c.Header("Cache-Control", "private")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
|
||||
// Check Session
|
||||
|
||||
@@ -407,7 +407,10 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
wantedDocs, err := api.db.Queries.GetWantedDocuments(c, string(jsonHaves))
|
||||
wantedDocs, err := api.db.Queries.GetWantedDocuments(c, database.GetWantedDocumentsParams{
|
||||
JsonEach: string(jsonHaves),
|
||||
DocumentIds: string(jsonHaves),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("GetWantedDocuments DB Error", err)
|
||||
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||
|
||||
@@ -25,12 +25,12 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
||||
documentID = *request.Params.DocumentId
|
||||
}
|
||||
|
||||
offset := int64(0)
|
||||
if request.Params.Offset != nil {
|
||||
offset = *request.Params.Offset
|
||||
page := int64(1)
|
||||
if request.Params.Page != nil {
|
||||
page = *request.Params.Page
|
||||
}
|
||||
|
||||
limit := int64(100)
|
||||
limit := int64(25)
|
||||
if request.Params.Limit != nil {
|
||||
limit = *request.Params.Limit
|
||||
}
|
||||
@@ -39,13 +39,33 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
||||
UserID: auth.UserName,
|
||||
DocFilter: docFilter,
|
||||
DocumentID: documentID,
|
||||
Offset: offset,
|
||||
Offset: (page - 1) * limit,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Get Total Count
|
||||
total, err := s.db.Queries.GetActivityCount(ctx, database.GetActivityCountParams{
|
||||
UserID: auth.UserName,
|
||||
DocFilter: docFilter,
|
||||
DocumentID: documentID,
|
||||
})
|
||||
if err != nil {
|
||||
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Calculate Pagination
|
||||
var nextPage *int64
|
||||
var previousPage *int64
|
||||
if page*limit < total {
|
||||
nextPage = ptrOf(page + 1)
|
||||
}
|
||||
if page > 1 {
|
||||
previousPage = ptrOf(page - 1)
|
||||
}
|
||||
|
||||
apiActivities := make([]Activity, len(activities))
|
||||
for i, a := range activities {
|
||||
// Convert StartTime from interface{} to string
|
||||
@@ -71,6 +91,11 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
||||
|
||||
response := ActivityResponse{
|
||||
Activities: apiActivities,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Total: total,
|
||||
NextPage: nextPage,
|
||||
PreviousPage: previousPage,
|
||||
}
|
||||
return GetActivity200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
@@ -159,6 +159,11 @@ type Activity struct {
|
||||
// ActivityResponse defines model for ActivityResponse.
|
||||
type ActivityResponse struct {
|
||||
Activities []Activity `json:"activities"`
|
||||
Limit int64 `json:"limit"`
|
||||
NextPage *int64 `json:"next_page,omitempty"`
|
||||
Page int64 `json:"page"`
|
||||
PreviousPage *int64 `json:"previous_page,omitempty"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// BackupType defines model for BackupType.
|
||||
@@ -236,7 +241,6 @@ type Document struct {
|
||||
// DocumentResponse defines model for DocumentResponse.
|
||||
type DocumentResponse struct {
|
||||
Document Document `json:"document"`
|
||||
Progress *Progress `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentsResponse defines model for DocumentsResponse.
|
||||
@@ -248,8 +252,6 @@ type DocumentsResponse struct {
|
||||
PreviousPage *int64 `json:"previous_page,omitempty"`
|
||||
Search *string `json:"search,omitempty"`
|
||||
Total int64 `json:"total"`
|
||||
User UserData `json:"user"`
|
||||
WordCounts []WordCount `json:"word_counts"`
|
||||
}
|
||||
|
||||
// ErrorResponse defines model for ErrorResponse.
|
||||
@@ -469,17 +471,11 @@ type UsersResponse struct {
|
||||
Users *[]User `json:"users,omitempty"`
|
||||
}
|
||||
|
||||
// WordCount defines model for WordCount.
|
||||
type WordCount struct {
|
||||
Count int64 `json:"count"`
|
||||
DocumentId string `json:"document_id"`
|
||||
}
|
||||
|
||||
// GetActivityParams defines parameters for GetActivity.
|
||||
type GetActivityParams struct {
|
||||
DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"`
|
||||
DocumentId *string `form:"document_id,omitempty" json:"document_id,omitempty"`
|
||||
Offset *int64 `form:"offset,omitempty" json:"offset,omitempty"`
|
||||
Page *int64 `form:"page,omitempty" json:"page,omitempty"`
|
||||
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
@@ -749,11 +745,11 @@ func (siw *ServerInterfaceWrapper) GetActivity(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "offset" -------------
|
||||
// ------------- Optional query parameter "page" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "offset", r.URL.Query(), ¶ms.Offset, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"})
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "page", r.URL.Query(), ¶ms.Page, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err})
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,16 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
limit = *request.Params.Limit
|
||||
}
|
||||
|
||||
search := ""
|
||||
if request.Params.Search != nil {
|
||||
search = "%" + *request.Params.Search + "%"
|
||||
var search *string
|
||||
if request.Params.Search != nil && *request.Params.Search != "" {
|
||||
search = ptrOf("%" + *request.Params.Search + "%")
|
||||
}
|
||||
|
||||
rows, err := s.db.Queries.GetDocumentsWithStats(
|
||||
ctx,
|
||||
database.GetDocumentsWithStatsParams{
|
||||
UserID: auth.UserName,
|
||||
Query: &search,
|
||||
Query: search,
|
||||
Deleted: ptrOf(false),
|
||||
Offset: (page - 1) * limit,
|
||||
Limit: limit,
|
||||
@@ -52,7 +52,19 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||
}
|
||||
|
||||
total := int64(len(rows))
|
||||
// Get Total Count
|
||||
total, err := s.db.Queries.GetDocumentsWithStatsCount(
|
||||
ctx,
|
||||
database.GetDocumentsWithStatsCountParams{
|
||||
Query: search,
|
||||
Deleted: ptrOf(false),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Calculate Pagination
|
||||
var nextPage *int64
|
||||
var previousPage *int64
|
||||
if page*limit < total {
|
||||
@@ -63,7 +75,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
}
|
||||
|
||||
apiDocuments := make([]Document, len(rows))
|
||||
wordCounts := make([]WordCount, 0, len(rows))
|
||||
for i, row := range rows {
|
||||
apiDocuments[i] = Document{
|
||||
Id: row.ID,
|
||||
@@ -83,12 +94,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
|
||||
Deleted: false, // Default, should be overridden if available
|
||||
}
|
||||
if row.Words != nil {
|
||||
wordCounts = append(wordCounts, WordCount{
|
||||
DocumentId: row.ID,
|
||||
Count: *row.Words,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response := DocumentsResponse{
|
||||
@@ -99,8 +104,6 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
NextPage: nextPage,
|
||||
PreviousPage: previousPage,
|
||||
Search: request.Params.Search,
|
||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||
WordCounts: wordCounts,
|
||||
}
|
||||
return GetDocuments200JSONResponse(response), nil
|
||||
}
|
||||
@@ -129,21 +132,6 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
||||
|
||||
doc := docs[0]
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: request.Id,
|
||||
})
|
||||
var progress *Progress
|
||||
if err == nil {
|
||||
progress = &Progress{
|
||||
UserId: &progressRow.UserID,
|
||||
DocumentId: &progressRow.DocumentID,
|
||||
DeviceName: &progressRow.DeviceName,
|
||||
Percentage: &progressRow.Percentage,
|
||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||
}
|
||||
}
|
||||
|
||||
apiDoc := Document{
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
@@ -165,7 +153,6 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
||||
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
Progress: progress,
|
||||
}
|
||||
return GetDocument200JSONResponse(response), nil
|
||||
}
|
||||
@@ -244,21 +231,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
||||
|
||||
doc := docs[0]
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: request.Id,
|
||||
})
|
||||
var progress *Progress
|
||||
if err == nil {
|
||||
progress = &Progress{
|
||||
UserId: &progressRow.UserID,
|
||||
DocumentId: &progressRow.DocumentID,
|
||||
DeviceName: &progressRow.DeviceName,
|
||||
Percentage: &progressRow.Percentage,
|
||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||
}
|
||||
}
|
||||
|
||||
apiDoc := Document{
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
@@ -280,7 +252,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
||||
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
Progress: progress,
|
||||
}
|
||||
return EditDocument200JSONResponse(response), nil
|
||||
}
|
||||
@@ -601,21 +572,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
|
||||
|
||||
doc := docs[0]
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: request.Id,
|
||||
})
|
||||
var progress *Progress
|
||||
if err == nil {
|
||||
progress = &Progress{
|
||||
UserId: &progressRow.UserID,
|
||||
DocumentId: &progressRow.DocumentID,
|
||||
DeviceName: &progressRow.DeviceName,
|
||||
Percentage: &progressRow.Percentage,
|
||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||
}
|
||||
}
|
||||
|
||||
apiDoc := Document{
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
@@ -637,7 +593,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
|
||||
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
Progress: progress,
|
||||
}
|
||||
return UploadDocumentCover200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
|
||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
suite.Equal(int64(1), resp.Page)
|
||||
suite.Equal(int64(9), resp.Limit)
|
||||
suite.Equal("testuser", resp.User.Username)
|
||||
}
|
||||
|
||||
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {
|
||||
|
||||
@@ -300,27 +300,17 @@ components:
|
||||
format: int64
|
||||
search:
|
||||
type: string
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
word_counts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WordCount'
|
||||
required:
|
||||
- documents
|
||||
- total
|
||||
- page
|
||||
- limit
|
||||
- user
|
||||
- word_counts
|
||||
|
||||
DocumentResponse:
|
||||
type: object
|
||||
properties:
|
||||
document:
|
||||
$ref: '#/components/schemas/Document'
|
||||
progress:
|
||||
$ref: '#/components/schemas/Progress'
|
||||
required:
|
||||
- document
|
||||
|
||||
@@ -360,8 +350,26 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Activity'
|
||||
page:
|
||||
type: integer
|
||||
format: int64
|
||||
limit:
|
||||
type: integer
|
||||
format: int64
|
||||
next_page:
|
||||
type: integer
|
||||
format: int64
|
||||
previous_page:
|
||||
type: integer
|
||||
format: int64
|
||||
total:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- activities
|
||||
- page
|
||||
- limit
|
||||
- total
|
||||
|
||||
Device:
|
||||
type: object
|
||||
@@ -1184,18 +1192,18 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: offset
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
default: 0
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
default: 100
|
||||
default: 25
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
|
||||
@@ -2,7 +2,6 @@ package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -43,13 +42,21 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq
|
||||
return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||
}
|
||||
|
||||
total := int64(len(progress))
|
||||
// Get Total Count
|
||||
total, err := s.db.Queries.GetProgressCount(ctx, database.GetProgressCountParams{
|
||||
UserID: auth.UserName,
|
||||
DocFilter: filter.DocFilter,
|
||||
DocumentID: filter.DocumentID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("GetProgressCount DB Error:", err)
|
||||
return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||
}
|
||||
|
||||
// Calculate Pagination
|
||||
var nextPage *int64
|
||||
var previousPage *int64
|
||||
|
||||
// Calculate total pages
|
||||
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
|
||||
if page < totalPages {
|
||||
if page*limit < total {
|
||||
nextPage = ptrOf(page + 1)
|
||||
}
|
||||
if page > 1 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/config"
|
||||
"reichard.io/antholume/database"
|
||||
)
|
||||
@@ -28,6 +29,10 @@ func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server
|
||||
assets: assets,
|
||||
}
|
||||
|
||||
if cfg.DisableAuth {
|
||||
log.Warn("DISABLE_AUTH is set — all API requests will bypass authentication")
|
||||
}
|
||||
|
||||
// Create strict handler with authentication middleware
|
||||
strictHandler := NewStrictHandler(s, []StrictMiddlewareFunc{s.authMiddleware})
|
||||
|
||||
@@ -51,6 +56,22 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
||||
return handler(ctx, w, r, request)
|
||||
}
|
||||
|
||||
// Dev Auth Bypass - Inject an admin session when DISABLE_AUTH is set.
|
||||
// This avoids repeated logins during local development. Uses the
|
||||
// first user in the database so that DB queries using the user ID
|
||||
// return real data.
|
||||
if s.cfg.DisableAuth {
|
||||
devAuth, ok := s.resolveDevAuth(ctx)
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
json.NewEncoder(w).Encode(ErrorResponse{Code: 500, Message: "DISABLE_AUTH: no users in database; register one first"})
|
||||
return nil, nil
|
||||
}
|
||||
ctx = context.WithValue(ctx, "auth", devAuth)
|
||||
return handler(ctx, w, r, request)
|
||||
}
|
||||
|
||||
auth, ok := s.getSession(r)
|
||||
if !ok {
|
||||
// Write 401 response directly
|
||||
@@ -89,6 +110,26 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDevAuth determines the dev user identity when DISABLE_AUTH is set.
|
||||
// If DISABLE_AUTH_USER is specified, that user is looked up; otherwise the
|
||||
// first user in the database is used.
|
||||
func (s *Server) resolveDevAuth(ctx context.Context) (authData, bool) {
|
||||
if s.cfg.DisableAuthUser != "" {
|
||||
user, err := s.db.Queries.GetUser(ctx, s.cfg.DisableAuthUser)
|
||||
if err != nil {
|
||||
log.Errorf("DISABLE_AUTH_USER=%q not found in database: %v", s.cfg.DisableAuthUser, err)
|
||||
return authData{}, false
|
||||
}
|
||||
return authData{UserName: user.ID, IsAdmin: user.Admin}, true
|
||||
}
|
||||
|
||||
users, err := s.db.Queries.GetUsers(ctx)
|
||||
if err != nil || len(users) == 0 {
|
||||
return authData{}, false
|
||||
}
|
||||
return authData{UserName: users[0].ID, IsAdmin: users[0].Admin}, true
|
||||
}
|
||||
|
||||
// GetInfo returns server information
|
||||
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
||||
return GetInfo200JSONResponse{
|
||||
|
||||
@@ -28,6 +28,8 @@ type Config struct {
|
||||
RegistrationEnabled bool
|
||||
SearchEnabled bool
|
||||
DemoMode bool
|
||||
DisableAuth bool
|
||||
DisableAuthUser string
|
||||
LogLevel string
|
||||
|
||||
// Cookie Settings
|
||||
@@ -63,6 +65,8 @@ func Load() *Config {
|
||||
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
|
||||
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
|
||||
DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true",
|
||||
DisableAuth: trimLowerString(getEnv("DISABLE_AUTH", "false")) == "true",
|
||||
DisableAuthUser: strings.TrimSpace(getEnv("DISABLE_AUTH_USER", "")),
|
||||
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
|
||||
CookieAuthKey: trimLowerString(getEnv("COOKIE_AUTH_KEY", "")),
|
||||
CookieEncKey: trimLowerString(getEnv("COOKIE_ENC_KEY", "")),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -396,3 +396,40 @@ SET
|
||||
isbn10 = COALESCE(excluded.isbn10, isbn10),
|
||||
isbn13 = COALESCE(excluded.isbn13, isbn13)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetDocumentsWithStatsCount :one
|
||||
SELECT COUNT(*) AS count
|
||||
FROM documents AS docs
|
||||
WHERE
|
||||
(docs.id = sqlc.narg('id') OR $id IS NULL)
|
||||
AND (docs.deleted = sqlc.narg(deleted) OR $deleted IS NULL)
|
||||
AND (
|
||||
(
|
||||
docs.title LIKE sqlc.narg('query') OR
|
||||
docs.author LIKE $query
|
||||
) OR $query IS NULL
|
||||
);
|
||||
|
||||
-- name: GetProgressCount :one
|
||||
SELECT COUNT(*) AS count
|
||||
FROM document_progress AS progress
|
||||
WHERE
|
||||
progress.user_id = $user_id
|
||||
AND (
|
||||
(
|
||||
CAST($doc_filter AS BOOLEAN) = TRUE
|
||||
AND document_id = $document_id
|
||||
) OR $doc_filter = FALSE
|
||||
);
|
||||
|
||||
-- name: GetActivityCount :one
|
||||
SELECT COUNT(*) AS count
|
||||
FROM activity
|
||||
WHERE
|
||||
activity.user_id = $user_id
|
||||
AND (
|
||||
(
|
||||
CAST($doc_filter AS BOOLEAN) = TRUE
|
||||
AND document_id = $document_id
|
||||
) OR $doc_filter = FALSE
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.31.1
|
||||
// source: query.sql
|
||||
|
||||
package database
|
||||
@@ -264,6 +264,32 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getActivityCount = `-- name: GetActivityCount :one
|
||||
SELECT COUNT(*) AS count
|
||||
FROM activity
|
||||
WHERE
|
||||
activity.user_id = ?1
|
||||
AND (
|
||||
(
|
||||
CAST(?2 AS BOOLEAN) = TRUE
|
||||
AND document_id = ?3
|
||||
) OR ?2 = FALSE
|
||||
)
|
||||
`
|
||||
|
||||
type GetActivityCountParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
DocFilter bool `json:"doc_filter"`
|
||||
DocumentID string `json:"document_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetActivityCount(ctx context.Context, arg GetActivityCountParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, getActivityCount, arg.UserID, arg.DocFilter, arg.DocumentID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getDailyReadStats = `-- name: GetDailyReadStats :many
|
||||
WITH RECURSIVE last_30_days AS (
|
||||
SELECT LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone) AS date
|
||||
@@ -734,6 +760,33 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getDocumentsWithStatsCount = `-- name: GetDocumentsWithStatsCount :one
|
||||
SELECT COUNT(*) AS count
|
||||
FROM documents AS docs
|
||||
WHERE
|
||||
(docs.id = ?1 OR ?1 IS NULL)
|
||||
AND (docs.deleted = ?2 OR ?2 IS NULL)
|
||||
AND (
|
||||
(
|
||||
docs.title LIKE ?3 OR
|
||||
docs.author LIKE ?3
|
||||
) OR ?3 IS NULL
|
||||
)
|
||||
`
|
||||
|
||||
type GetDocumentsWithStatsCountParams struct {
|
||||
ID *string `json:"id"`
|
||||
Deleted *bool `json:"-"`
|
||||
Query *string `json:"query"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDocumentsWithStatsCount(ctx context.Context, arg GetDocumentsWithStatsCountParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDocumentsWithStatsCount, arg.ID, arg.Deleted, arg.Query)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getLastActivity = `-- name: GetLastActivity :one
|
||||
SELECT start_time
|
||||
FROM activity
|
||||
@@ -897,6 +950,32 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]Get
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getProgressCount = `-- name: GetProgressCount :one
|
||||
SELECT COUNT(*) AS count
|
||||
FROM document_progress AS progress
|
||||
WHERE
|
||||
progress.user_id = ?1
|
||||
AND (
|
||||
(
|
||||
CAST(?2 AS BOOLEAN) = TRUE
|
||||
AND document_id = ?3
|
||||
) OR ?2 = FALSE
|
||||
)
|
||||
`
|
||||
|
||||
type GetProgressCountParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
DocFilter bool `json:"doc_filter"`
|
||||
DocumentID string `json:"document_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProgressCount(ctx context.Context, arg GetProgressCountParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProgressCount, arg.UserID, arg.DocFilter, arg.DocumentID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT id, pass, auth_hash, admin, timezone, created_at FROM users
|
||||
WHERE id = ?1 LIMIT 1
|
||||
@@ -1088,17 +1167,22 @@ WHERE (
|
||||
AND documents.filepath IS NULL
|
||||
)
|
||||
OR (documents.id IS NULL)
|
||||
OR CAST(?1 AS TEXT) != CAST(?1 AS TEXT)
|
||||
OR CAST(?2 AS TEXT) != CAST(?2 AS TEXT)
|
||||
`
|
||||
|
||||
type GetWantedDocumentsParams struct {
|
||||
JsonEach interface{} `json:"json_each"`
|
||||
DocumentIds string `json:"document_ids"`
|
||||
}
|
||||
|
||||
type GetWantedDocumentsRow struct {
|
||||
ID string `json:"id"`
|
||||
WantFile bool `json:"want_file"`
|
||||
WantMetadata bool `json:"want_metadata"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetWantedDocuments(ctx context.Context, documentIds string) ([]GetWantedDocumentsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWantedDocuments, documentIds)
|
||||
func (q *Queries) GetWantedDocuments(ctx context.Context, arg GetWantedDocumentsParams) ([]GetWantedDocumentsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWantedDocuments, arg.JsonEach, arg.DocumentIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
air
|
||||
go
|
||||
golangci-lint
|
||||
gopls
|
||||
@@ -27,6 +28,7 @@
|
||||
bun
|
||||
nodejs
|
||||
tailwindcss
|
||||
typescript-language-server
|
||||
];
|
||||
shellHook = ''
|
||||
export PATH=$PATH:~/go/bin
|
||||
|
||||
@@ -64,7 +64,18 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
- `bun run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build.
|
||||
- When possible, validate changed files directly before escalating to full-project fixes.
|
||||
|
||||
## 7) Updating This File
|
||||
## 7) Live Dev Server Debugging
|
||||
|
||||
- Use `glimpse` to inspect the running Vite dev server at `localhost:5173`:
|
||||
```bash
|
||||
glimpse snapshot http://localhost:5173/some-page --wait-until=complete --timeout=15000
|
||||
glimpse screenshot http://localhost:5173/some-page --wait-until=complete --output=_scratch/page.png
|
||||
glimpse exec http://localhost:5173/some-page --wait-until=complete --timeout=20000 --js='return document.title'
|
||||
```
|
||||
- Use `curl` for API endpoint testing (both `localhost:5173` via proxy and `localhost:8585` directly).
|
||||
- Do not monkey-patch `window.fetch` in `glimpse exec`; Firefox rejects it. Test API calls with `curl` instead.
|
||||
|
||||
## 8) Updating This File
|
||||
|
||||
After completing a frontend task, update this file if you learned something general that would help future frontend agents.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
|
||||
51
frontend/src/components/Pagination.tsx
Normal file
51
frontend/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
previousPage?: number;
|
||||
nextPage?: number;
|
||||
total?: number;
|
||||
limit?: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
previousPage,
|
||||
nextPage,
|
||||
total,
|
||||
limit,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
if (!previousPage && !nextPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalPages = total && limit ? Math.ceil(total / limit) : undefined;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full items-center justify-center gap-4 text-content">
|
||||
{previousPage && previousPage > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
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-none"
|
||||
>
|
||||
◄
|
||||
</button>
|
||||
) : null}
|
||||
{totalPages ? (
|
||||
<span className="text-sm text-content-muted">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
) : null}
|
||||
{nextPage && nextPage > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
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-none"
|
||||
>
|
||||
►
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
InlineLoader,
|
||||
} from './Skeleton';
|
||||
export { LoadingState } from './LoadingState';
|
||||
export { Pagination } from './Pagination';
|
||||
|
||||
// Field components
|
||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||
|
||||
@@ -9,4 +9,9 @@ import type { Activity } from './activity';
|
||||
|
||||
export interface ActivityResponse {
|
||||
activities: Activity[];
|
||||
page: number;
|
||||
limit: number;
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Document } from './document';
|
||||
import type { Progress } from './progress';
|
||||
|
||||
export interface DocumentResponse {
|
||||
document: Document;
|
||||
progress?: Progress;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Document } from './document';
|
||||
import type { UserData } from './userData';
|
||||
import type { WordCount } from './wordCount';
|
||||
|
||||
export interface DocumentsResponse {
|
||||
documents: Document[];
|
||||
@@ -17,6 +15,4 @@ export interface DocumentsResponse {
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
search?: string;
|
||||
user: UserData;
|
||||
word_counts: WordCount[];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
export type GetActivityParams = {
|
||||
doc_filter?: boolean;
|
||||
document_id?: string;
|
||||
offset?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||
const activities = data?.status === 200 ? data.data.activities : [];
|
||||
const [searchParams] = useSearchParams();
|
||||
const documentID = searchParams.get('document') || undefined;
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 25;
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [documentID]);
|
||||
|
||||
const { data, isLoading } = useGetActivity({
|
||||
doc_filter: Boolean(documentID),
|
||||
document_id: documentID,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
const response = data?.status === 200 ? data.data : undefined;
|
||||
const activities = response?.activities ?? [];
|
||||
|
||||
const columns: Column<Activity>[] = [
|
||||
{
|
||||
@@ -35,5 +52,17 @@ export default function ActivityPage() {
|
||||
},
|
||||
];
|
||||
|
||||
return <Table columns={columns} data={activities || []} loading={isLoading} />;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Table columns={columns} data={activities} loading={isLoading} />
|
||||
<Pagination
|
||||
page={page}
|
||||
previousPage={response?.previous_page}
|
||||
nextPage={response?.next_page}
|
||||
total={response?.total}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getGetDocumentQueryKey,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import { Document } from '../generated/model/document';
|
||||
import { Progress } from '../generated/model/progress';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import {
|
||||
DeleteIcon,
|
||||
@@ -54,15 +53,12 @@ export default function DocumentPage() {
|
||||
}
|
||||
|
||||
const document = docData.data.document as Document;
|
||||
const progress =
|
||||
docData?.status === 200 ? (docData.data.progress as Progress | undefined) : undefined;
|
||||
|
||||
if (!document) {
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
const percentage =
|
||||
document.percentage ?? (progress?.percentage ? progress.percentage * 100 : 0) ?? 0;
|
||||
const percentage = document.percentage ?? 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document, DocumentsResponse } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||
import { LoadingState } from '../components';
|
||||
import { LoadingState, Pagination } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
@@ -272,24 +272,14 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full justify-center gap-4 text-content">
|
||||
{previousPage && previousPage > 0 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
|
||||
>
|
||||
◄
|
||||
</button>
|
||||
)}
|
||||
{nextPage && nextPage > 0 && (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
|
||||
>
|
||||
►
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
previousPage={previousPage}
|
||||
nextPage={nextPage}
|
||||
total={(data?.data as DocumentsResponse | undefined)?.total}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
||||
<input
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
import type { Progress } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
|
||||
export default function ProgressPage() {
|
||||
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
||||
const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 15;
|
||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||
const response = data?.status === 200 ? data.data : undefined;
|
||||
const progress = response?.progress ?? [];
|
||||
|
||||
const columns: Column<Progress>[] = [
|
||||
{
|
||||
@@ -35,5 +40,17 @@ export default function ProgressPage() {
|
||||
},
|
||||
];
|
||||
|
||||
return <Table columns={columns} data={progress || []} loading={isLoading} />;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Table columns={columns} data={progress} loading={isLoading} />
|
||||
<Pagination
|
||||
page={page}
|
||||
previousPage={response?.previous_page}
|
||||
nextPage={response?.next_page}
|
||||
total={response?.total}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: ['lin-va-terminal'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8585',
|
||||
|
||||
Reference in New Issue
Block a user