4 Commits

Author SHA1 Message Date
64aa000323 build(dev): use air for backend live reload
Some checks failed
continuous-integration/drone/pr Build is failing
2026-05-02 15:39:28 -04:00
a865457bbf build(dev): split frontend and backend dev targets 2026-05-02 15:36:04 -04:00
a950d50440 feat(dev): add local auth bypass mode
Some checks failed
continuous-integration/drone/pr Build is failing
2026-05-02 15:32:48 -04:00
00faf9cea8 feat(pagination): paginate activity and progress lists 2026-05-02 15:32:10 -04:00
29 changed files with 539 additions and 84 deletions

17
.air.toml Normal file
View 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
View File

@@ -2,6 +2,7 @@ TODO.md
.DS_Store .DS_Store
data/ data/
build/ build/
tmp/
.direnv/ .direnv/
cover.html cover.html
node_modules node_modules

View File

@@ -41,16 +41,20 @@ Regenerate:
## 4) Backend / Assets ## 4) Backend / Assets
### Common commands ### Common commands
- Dev server: `make dev` - Full dev stack: `make dev` (backend on `:8585` + Vite frontend on `:5173`)
- Direct dev run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve` - 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` - Tests: `make tests`
- Tailwind asset build: `make build_tailwind` - Legacy server-rendered Tailwind asset build: `make legacy_tailwind`
### 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`. - 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 +67,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.

View File

@@ -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 go mod download
rm -r ./build || true rm -r ./build || true
mkdir -p ./build 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=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 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 -t antholume:latest .
docker_build_release_dev: build_tailwind docker_build_release_dev: legacy_tailwind
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/antholume:dev \ -t gitea.va.reichard.io/evan/antholume:dev \
-f Dockerfile-BuildKit \ -f Dockerfile-BuildKit \
--push . --push .
docker_build_release_latest: build_tailwind docker_build_release_latest: legacy_tailwind
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/antholume:latest \ -t gitea.va.reichard.io/evan/antholume:latest \
@@ -26,18 +37,19 @@ docker_build_release_latest: build_tailwind
-f Dockerfile-BuildKit \ -f Dockerfile-BuildKit \
--push . --push .
build_tailwind: build_tailwind: legacy_tailwind
legacy_tailwind:
tailwindcss build -o ./assets/style.css --minify tailwindcss build -o ./assets/style.css --minify
dev: build_tailwind dev:
GIN_MODE=release \ $(MAKE) -j2 dev_backend dev_frontend
CONFIG_PATH=./data \
DATA_PATH=./data \ dev_backend:
SEARCH_ENABLED=true \ $(DEV_ENV) air
REGISTRATION_ENABLED=true \
COOKIE_SECURE=false \ dev_frontend:
COOKIE_AUTH_KEY=1234 \ cd frontend && bun run dev
LOG_LEVEL=debug go run main.go serve
clean: clean:
rm -rf ./build rm -rf ./build

View File

@@ -134,7 +134,12 @@ go install github.com/pressly/goose/v3/cmd/goose@latest
Run Development: Run Development:
```bash ```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 ## Building
@@ -152,8 +157,8 @@ make docker_build_local
make docker_build_release_latest make docker_build_release_latest
make docker_build_release_dev make docker_build_release_dev
# Generate Tailwind CSS # Generate legacy Tailwind CSS for server-rendered templates
make build_tailwind make legacy_tailwind
# Clean Local Build # Clean Local Build
make clean make clean

View File

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

View File

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

View File

@@ -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
@@ -71,6 +91,11 @@ 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
} }

View File

@@ -159,6 +159,11 @@ 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(), &params.Offset, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) err = runtime.BindQueryParameterWithOptions("form", true, false, "page", r.URL.Query(), &params.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
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", "")),

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@
{ {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
air
go go
golangci-lint golangci-lint
gopls gopls
@@ -27,6 +28,7 @@
bun bun
nodejs nodejs
tailwindcss tailwindcss
typescript-language-server
]; ];
shellHook = '' shellHook = ''
export PATH=$PATH:~/go/bin export PATH=$PATH:~/go/bin

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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