Compare commits
2 Commits
75c872264f
...
a950d50440
| Author | SHA1 | Date | |
|---|---|---|---|
| a950d50440 | |||
| 00faf9cea8 |
21
AGENTS.md
21
AGENTS.md
@@ -43,6 +43,7 @@ Regenerate:
|
|||||||
### Common commands
|
### Common commands
|
||||||
- Dev server: `make dev`
|
- Dev server: `make dev`
|
||||||
- Direct dev run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve`
|
- Direct dev 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`
|
- Tests: `make tests`
|
||||||
- Tailwind asset build: `make build_tailwind`
|
- Tailwind asset build: `make build_tailwind`
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ Regenerate:
|
|||||||
- Root Tailwind output is built to `assets/style.css`.
|
- 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.
|
||||||
|
|
||||||
## 5) Frontend
|
## 5) Frontend
|
||||||
|
|
||||||
@@ -63,7 +65,24 @@ For frontend-specific implementation notes and commands, also read:
|
|||||||
- Frontend API client: `cd frontend && bun run generate:api`
|
- Frontend API client: `cd frontend && bun run generate:api`
|
||||||
- SQLC: `sqlc generate`
|
- 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.
|
After completing a task, update this `AGENTS.md` if you learned something general that would help future agents.
|
||||||
|
|
||||||
|
|||||||
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) {
|
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)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session First
|
// Check Session First
|
||||||
@@ -89,6 +127,16 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authOPDSMiddleware(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"`)
|
c.Header("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||||
|
|
||||||
user, rawPassword, hasAuth := c.Request.BasicAuth()
|
user, rawPassword, hasAuth := c.Request.BasicAuth()
|
||||||
@@ -113,6 +161,16 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authWebAppMiddleware(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)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session
|
// Check Session
|
||||||
|
|||||||
@@ -407,7 +407,10 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
log.Error("GetWantedDocuments DB Error", err)
|
log.Error("GetWantedDocuments DB Error", err)
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
|||||||
documentID = *request.Params.DocumentId
|
documentID = *request.Params.DocumentId
|
||||||
}
|
}
|
||||||
|
|
||||||
offset := int64(0)
|
page := int64(1)
|
||||||
if request.Params.Offset != nil {
|
if request.Params.Page != nil {
|
||||||
offset = *request.Params.Offset
|
page = *request.Params.Page
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := int64(100)
|
limit := int64(25)
|
||||||
if request.Params.Limit != nil {
|
if request.Params.Limit != nil {
|
||||||
limit = *request.Params.Limit
|
limit = *request.Params.Limit
|
||||||
}
|
}
|
||||||
@@ -39,13 +39,33 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
|||||||
UserID: auth.UserName,
|
UserID: auth.UserName,
|
||||||
DocFilter: docFilter,
|
DocFilter: docFilter,
|
||||||
DocumentID: documentID,
|
DocumentID: documentID,
|
||||||
Offset: offset,
|
Offset: (page - 1) * limit,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, 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))
|
apiActivities := make([]Activity, len(activities))
|
||||||
for i, a := range activities {
|
for i, a := range activities {
|
||||||
// Convert StartTime from interface{} to string
|
// Convert StartTime from interface{} to string
|
||||||
@@ -70,7 +90,12 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := ActivityResponse{
|
response := ActivityResponse{
|
||||||
Activities: apiActivities,
|
Activities: apiActivities,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
Total: total,
|
||||||
|
NextPage: nextPage,
|
||||||
|
PreviousPage: previousPage,
|
||||||
}
|
}
|
||||||
return GetActivity200JSONResponse(response), nil
|
return GetActivity200JSONResponse(response), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,12 @@ type Activity struct {
|
|||||||
|
|
||||||
// ActivityResponse defines model for ActivityResponse.
|
// ActivityResponse defines model for ActivityResponse.
|
||||||
type ActivityResponse struct {
|
type ActivityResponse struct {
|
||||||
Activities []Activity `json:"activities"`
|
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.
|
// BackupType defines model for BackupType.
|
||||||
@@ -470,7 +475,7 @@ type UsersResponse struct {
|
|||||||
type GetActivityParams struct {
|
type GetActivityParams struct {
|
||||||
DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"`
|
DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"`
|
||||||
DocumentId *string `form:"document_id,omitempty" json:"document_id,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"`
|
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,11 +745,11 @@ func (siw *ServerInterfaceWrapper) GetActivity(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err})
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
|||||||
limit = *request.Params.Limit
|
limit = *request.Params.Limit
|
||||||
}
|
}
|
||||||
|
|
||||||
search := ""
|
var search *string
|
||||||
if request.Params.Search != nil {
|
if request.Params.Search != nil && *request.Params.Search != "" {
|
||||||
search = "%" + *request.Params.Search + "%"
|
search = ptrOf("%" + *request.Params.Search + "%")
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Queries.GetDocumentsWithStats(
|
rows, err := s.db.Queries.GetDocumentsWithStats(
|
||||||
ctx,
|
ctx,
|
||||||
database.GetDocumentsWithStatsParams{
|
database.GetDocumentsWithStatsParams{
|
||||||
UserID: auth.UserName,
|
UserID: auth.UserName,
|
||||||
Query: &search,
|
Query: search,
|
||||||
Deleted: ptrOf(false),
|
Deleted: ptrOf(false),
|
||||||
Offset: (page - 1) * limit,
|
Offset: (page - 1) * limit,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
@@ -52,7 +52,19 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
|||||||
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
|
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 nextPage *int64
|
||||||
var previousPage *int64
|
var previousPage *int64
|
||||||
if page*limit < total {
|
if page*limit < total {
|
||||||
@@ -219,7 +231,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
|||||||
|
|
||||||
doc := docs[0]
|
doc := docs[0]
|
||||||
|
|
||||||
|
|
||||||
apiDoc := Document{
|
apiDoc := Document{
|
||||||
Id: doc.ID,
|
Id: doc.ID,
|
||||||
Title: *doc.Title,
|
Title: *doc.Title,
|
||||||
@@ -561,7 +572,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
|
|||||||
|
|
||||||
doc := docs[0]
|
doc := docs[0]
|
||||||
|
|
||||||
|
|
||||||
apiDoc := Document{
|
apiDoc := Document{
|
||||||
Id: doc.ID,
|
Id: doc.ID,
|
||||||
Title: *doc.Title,
|
Title: *doc.Title,
|
||||||
|
|||||||
@@ -350,8 +350,26 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Activity'
|
$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:
|
required:
|
||||||
- activities
|
- activities
|
||||||
|
- page
|
||||||
|
- limit
|
||||||
|
- total
|
||||||
|
|
||||||
Device:
|
Device:
|
||||||
type: object
|
type: object
|
||||||
@@ -1174,18 +1192,18 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- name: offset
|
- name: page
|
||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
default: 0
|
default: 1
|
||||||
- name: limit
|
- name: limit
|
||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
default: 100
|
default: 25
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
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
|
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 nextPage *int64
|
||||||
var previousPage *int64
|
var previousPage *int64
|
||||||
|
if page*limit < total {
|
||||||
// Calculate total pages
|
|
||||||
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
|
|
||||||
if page < totalPages {
|
|
||||||
nextPage = ptrOf(page + 1)
|
nextPage = ptrOf(page + 1)
|
||||||
}
|
}
|
||||||
if page > 1 {
|
if page > 1 {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/antholume/config"
|
"reichard.io/antholume/config"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
)
|
)
|
||||||
@@ -28,6 +29,10 @@ func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server
|
|||||||
assets: assets,
|
assets: assets,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.DisableAuth {
|
||||||
|
log.Warn("DISABLE_AUTH is set — all API requests will bypass authentication")
|
||||||
|
}
|
||||||
|
|
||||||
// Create strict handler with authentication middleware
|
// Create strict handler with authentication middleware
|
||||||
strictHandler := NewStrictHandler(s, []StrictMiddlewareFunc{s.authMiddleware})
|
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)
|
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)
|
auth, ok := s.getSession(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Write 401 response directly
|
// 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
|
// GetInfo returns server information
|
||||||
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
||||||
return GetInfo200JSONResponse{
|
return GetInfo200JSONResponse{
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type Config struct {
|
|||||||
RegistrationEnabled bool
|
RegistrationEnabled bool
|
||||||
SearchEnabled bool
|
SearchEnabled bool
|
||||||
DemoMode bool
|
DemoMode bool
|
||||||
|
DisableAuth bool
|
||||||
|
DisableAuthUser string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
|
||||||
// Cookie Settings
|
// Cookie Settings
|
||||||
@@ -63,6 +65,8 @@ func Load() *Config {
|
|||||||
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
|
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
|
||||||
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
|
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
|
||||||
DemoMode: trimLowerString(getEnv("DEMO_MODE", "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",
|
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
|
||||||
CookieAuthKey: trimLowerString(getEnv("COOKIE_AUTH_KEY", "")),
|
CookieAuthKey: trimLowerString(getEnv("COOKIE_AUTH_KEY", "")),
|
||||||
CookieEncKey: trimLowerString(getEnv("COOKIE_ENC_KEY", "")),
|
CookieEncKey: trimLowerString(getEnv("COOKIE_ENC_KEY", "")),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -396,3 +396,40 @@ SET
|
|||||||
isbn10 = COALESCE(excluded.isbn10, isbn10),
|
isbn10 = COALESCE(excluded.isbn10, isbn10),
|
||||||
isbn13 = COALESCE(excluded.isbn13, isbn13)
|
isbn13 = COALESCE(excluded.isbn13, isbn13)
|
||||||
RETURNING *;
|
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.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.31.1
|
||||||
// source: query.sql
|
// source: query.sql
|
||||||
|
|
||||||
package database
|
package database
|
||||||
@@ -264,6 +264,32 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
|
|||||||
return items, nil
|
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
|
const getDailyReadStats = `-- name: GetDailyReadStats :many
|
||||||
WITH RECURSIVE last_30_days AS (
|
WITH RECURSIVE last_30_days AS (
|
||||||
SELECT LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone) AS date
|
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
|
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
|
const getLastActivity = `-- name: GetLastActivity :one
|
||||||
SELECT start_time
|
SELECT start_time
|
||||||
FROM activity
|
FROM activity
|
||||||
@@ -897,6 +950,32 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]Get
|
|||||||
return items, nil
|
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
|
const getUser = `-- name: GetUser :one
|
||||||
SELECT id, pass, auth_hash, admin, timezone, created_at FROM users
|
SELECT id, pass, auth_hash, admin, timezone, created_at FROM users
|
||||||
WHERE id = ?1 LIMIT 1
|
WHERE id = ?1 LIMIT 1
|
||||||
@@ -1088,17 +1167,22 @@ WHERE (
|
|||||||
AND documents.filepath IS NULL
|
AND documents.filepath IS NULL
|
||||||
)
|
)
|
||||||
OR (documents.id 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 {
|
type GetWantedDocumentsRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
WantFile bool `json:"want_file"`
|
WantFile bool `json:"want_file"`
|
||||||
WantMetadata bool `json:"want_metadata"`
|
WantMetadata bool `json:"want_metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetWantedDocuments(ctx context.Context, documentIds string) ([]GetWantedDocumentsRow, error) {
|
func (q *Queries) GetWantedDocuments(ctx context.Context, arg GetWantedDocumentsParams) ([]GetWantedDocumentsRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getWantedDocuments, documentIds)
|
rows, err := q.db.QueryContext(ctx, getWantedDocuments, arg.JsonEach, arg.DocumentIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
bun
|
bun
|
||||||
nodejs
|
nodejs
|
||||||
tailwindcss
|
tailwindcss
|
||||||
|
typescript-language-server
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export PATH=$PATH:~/go/bin
|
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.
|
- `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) 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.
|
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",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"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,
|
InlineLoader,
|
||||||
} from './Skeleton';
|
} from './Skeleton';
|
||||||
export { LoadingState } from './LoadingState';
|
export { LoadingState } from './LoadingState';
|
||||||
|
export { Pagination } from './Pagination';
|
||||||
|
|
||||||
// Field components
|
// Field components
|
||||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||||
|
|||||||
@@ -9,4 +9,9 @@ import type { Activity } from './activity';
|
|||||||
|
|
||||||
export interface ActivityResponse {
|
export interface ActivityResponse {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
next_page?: number;
|
||||||
|
previous_page?: number;
|
||||||
|
total: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
export type GetActivityParams = {
|
export type GetActivityParams = {
|
||||||
doc_filter?: boolean;
|
doc_filter?: boolean;
|
||||||
document_id?: string;
|
document_id?: string;
|
||||||
offset?: number;
|
page?: number;
|
||||||
limit?: 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 { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Activity } from '../generated/model';
|
import type { Activity } from '../generated/model';
|
||||||
|
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';
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
const [searchParams] = useSearchParams();
|
||||||
const activities = data?.status === 200 ? data.data.activities : [];
|
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>[] = [
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Document, DocumentsResponse } from '../generated/model';
|
import type { Document, DocumentsResponse } from '../generated/model';
|
||||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
import { LoadingState } from '../components';
|
import { LoadingState, Pagination } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
@@ -272,24 +272,14 @@ export default function DocumentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex w-full justify-center gap-4 text-content">
|
<Pagination
|
||||||
{previousPage && previousPage > 0 && (
|
page={page}
|
||||||
<button
|
previousPage={previousPage}
|
||||||
onClick={() => setPage(page - 1)}
|
nextPage={nextPage}
|
||||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
|
total={(data?.data as DocumentsResponse | undefined)?.total}
|
||||||
>
|
limit={limit}
|
||||||
◄
|
onPageChange={setPage}
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Progress } from '../generated/model';
|
import type { Progress } from '../generated/model';
|
||||||
|
import { Pagination } from '../components';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
|
|
||||||
export default function ProgressPage() {
|
export default function ProgressPage() {
|
||||||
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
const [page, setPage] = useState(1);
|
||||||
const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
|
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>[] = [
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ['lin-va-terminal'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8585',
|
target: 'http://localhost:8585',
|
||||||
|
|||||||
Reference in New Issue
Block a user