wip 16
This commit is contained in:
38
AGENTS.md
38
AGENTS.md
@@ -1,41 +1,31 @@
|
|||||||
# AnthoLume - Agent Context
|
# AnthoLume - Agent Context
|
||||||
|
|
||||||
## Migration Context
|
|
||||||
Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec)
|
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|
||||||
### Generated Files
|
### Generated Files
|
||||||
- **NEVER edit generated files** - Always edit the source and regenerate
|
- **NEVER edit generated files directly** - Always edit the source and regenerate
|
||||||
- Go backend API: Edit `api/v1/openapi.yaml` then run `go generate ./api/v1/generate.go`
|
- Go backend API: Edit `api/v1/openapi.yaml` then run:
|
||||||
- TS client: Regenerate with `cd frontend && npm run generate:api`
|
- `go generate ./api/v1/generate.go`
|
||||||
- Examples of generated files:
|
- `cd frontend && bun run generate:api`
|
||||||
- `api/v1/api.gen.go`
|
- Examples of generated files:
|
||||||
- `frontend/src/generated/**/*.ts`
|
- `api/v1/api.gen.go`
|
||||||
|
- `frontend/src/generated/**/*.ts`
|
||||||
|
|
||||||
### Database Access
|
### Database Access
|
||||||
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
|
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
|
||||||
- Migrate V1 API by mirroring legacy implementation in `api/app-admin-routes.go` and `api/app-routes.go`
|
- Define queries in `database/query.sql` and regenerate via `sqlc generate`
|
||||||
|
|
||||||
### Migration Workflow
|
### Error Handling
|
||||||
1. Check legacy implementation for business logic
|
- Use `fmt.Errorf("message: %w", err)` for wrapping errors
|
||||||
2. Copy pattern but adapt to use `s.db.Queries.*` instead of `api.db.Queries.*`
|
- Do NOT use `github.com/pkg/errors`
|
||||||
3. Map legacy response types to V1 API response types
|
|
||||||
4. Never create new DB queries
|
|
||||||
|
|
||||||
### Surprises
|
|
||||||
- Templates may show fields the API doesn't return - cross-check with DB query
|
|
||||||
- `start_time` is `interface{}` in Go models, needs type assertion in Go
|
|
||||||
- Templates use `LOCAL_TIME()` SQL function for timezone-aware display
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
Use `fmt.Errorf("message: %w", err)` for wrapping. Do NOT use `github.com/pkg/errors`.
|
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
- **Package manager**: bun (not npm)
|
- **Package manager**: bun (not npm)
|
||||||
|
- **Icons**: Use `lucide-react` for all icons (not custom SVGs)
|
||||||
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
|
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
|
||||||
- **Format**: `cd frontend && bun run format` (and `format:fix`)
|
- **Format**: `cd frontend && bun run format` (and `format:fix`)
|
||||||
|
- **Generate API client**: `cd frontend && bun run generate:api`
|
||||||
|
|
||||||
## Regeneration
|
## Regeneration
|
||||||
- Go backend: `go generate ./api/v1/generate.go`
|
- Go backend: `go generate ./api/v1/generate.go`
|
||||||
- TS client: `cd frontend && npm run generate:api`
|
- TS client: `cd frontend && bun run generate:api`
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
|||||||
|
|
||||||
response := ActivityResponse{
|
response := ActivityResponse{
|
||||||
Activities: apiActivities,
|
Activities: apiActivities,
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
}
|
}
|
||||||
return GetActivity200JSONResponse(response), nil
|
return GetActivity200JSONResponse(response), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ 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"`
|
||||||
User UserData `json:"user"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupType defines model for BackupType.
|
// BackupType defines model for BackupType.
|
||||||
@@ -217,7 +216,6 @@ type Document struct {
|
|||||||
type DocumentResponse struct {
|
type DocumentResponse struct {
|
||||||
Document Document `json:"document"`
|
Document Document `json:"document"`
|
||||||
Progress *Progress `json:"progress,omitempty"`
|
Progress *Progress `json:"progress,omitempty"`
|
||||||
User UserData `json:"user"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DocumentsResponse defines model for DocumentsResponse.
|
// DocumentsResponse defines model for DocumentsResponse.
|
||||||
@@ -248,7 +246,6 @@ type GraphDataPoint struct {
|
|||||||
// GraphDataResponse defines model for GraphDataResponse.
|
// GraphDataResponse defines model for GraphDataResponse.
|
||||||
type GraphDataResponse struct {
|
type GraphDataResponse struct {
|
||||||
GraphData []GraphDataPoint `json:"graph_data"`
|
GraphData []GraphDataPoint `json:"graph_data"`
|
||||||
User UserData `json:"user"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HomeResponse defines model for HomeResponse.
|
// HomeResponse defines model for HomeResponse.
|
||||||
@@ -256,7 +253,6 @@ type HomeResponse struct {
|
|||||||
DatabaseInfo DatabaseInfo `json:"database_info"`
|
DatabaseInfo DatabaseInfo `json:"database_info"`
|
||||||
GraphData GraphDataResponse `json:"graph_data"`
|
GraphData GraphDataResponse `json:"graph_data"`
|
||||||
Streaks StreaksResponse `json:"streaks"`
|
Streaks StreaksResponse `json:"streaks"`
|
||||||
User UserData `json:"user"`
|
|
||||||
UserStatistics UserStatisticsResponse `json:"user_statistics"`
|
UserStatistics UserStatisticsResponse `json:"user_statistics"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,13 +345,11 @@ type ProgressListResponse struct {
|
|||||||
PreviousPage *int64 `json:"previous_page,omitempty"`
|
PreviousPage *int64 `json:"previous_page,omitempty"`
|
||||||
Progress *[]Progress `json:"progress,omitempty"`
|
Progress *[]Progress `json:"progress,omitempty"`
|
||||||
Total *int64 `json:"total,omitempty"`
|
Total *int64 `json:"total,omitempty"`
|
||||||
User *UserData `json:"user,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressResponse defines model for ProgressResponse.
|
// ProgressResponse defines model for ProgressResponse.
|
||||||
type ProgressResponse struct {
|
type ProgressResponse struct {
|
||||||
Progress *Progress `json:"progress,omitempty"`
|
Progress *Progress `json:"progress,omitempty"`
|
||||||
User *UserData `json:"user,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchItem defines model for SearchItem.
|
// SearchItem defines model for SearchItem.
|
||||||
@@ -387,7 +381,6 @@ type SettingsResponse struct {
|
|||||||
// StreaksResponse defines model for StreaksResponse.
|
// StreaksResponse defines model for StreaksResponse.
|
||||||
type StreaksResponse struct {
|
type StreaksResponse struct {
|
||||||
Streaks []UserStreak `json:"streaks"`
|
Streaks []UserStreak `json:"streaks"`
|
||||||
User UserData `json:"user"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettingsRequest defines model for UpdateSettingsRequest.
|
// UpdateSettingsRequest defines model for UpdateSettingsRequest.
|
||||||
@@ -413,7 +406,6 @@ type UserData struct {
|
|||||||
// UserStatisticsResponse defines model for UserStatisticsResponse.
|
// UserStatisticsResponse defines model for UserStatisticsResponse.
|
||||||
type UserStatisticsResponse struct {
|
type UserStatisticsResponse struct {
|
||||||
Duration LeaderboardData `json:"duration"`
|
Duration LeaderboardData `json:"duration"`
|
||||||
User UserData `json:"user"`
|
|
||||||
Words LeaderboardData `json:"words"`
|
Words LeaderboardData `json:"words"`
|
||||||
Wpm LeaderboardData `json:"wpm"`
|
Wpm LeaderboardData `json:"wpm"`
|
||||||
}
|
}
|
||||||
@@ -495,6 +487,21 @@ type CreateDocumentMultipartBody struct {
|
|||||||
DocumentFile openapi_types.File `json:"document_file"`
|
DocumentFile openapi_types.File `json:"document_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditDocumentJSONBody defines parameters for EditDocument.
|
||||||
|
type EditDocumentJSONBody struct {
|
||||||
|
Author *string `json:"author,omitempty"`
|
||||||
|
CoverGbid *string `json:"cover_gbid,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Isbn10 *string `json:"isbn10,omitempty"`
|
||||||
|
Isbn13 *string `json:"isbn13,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadDocumentCoverMultipartBody defines parameters for UploadDocumentCover.
|
||||||
|
type UploadDocumentCoverMultipartBody struct {
|
||||||
|
CoverFile openapi_types.File `json:"cover_file"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetProgressListParams defines parameters for GetProgressList.
|
// GetProgressListParams defines parameters for GetProgressList.
|
||||||
type GetProgressListParams struct {
|
type GetProgressListParams struct {
|
||||||
Page *int64 `form:"page,omitempty" json:"page,omitempty"`
|
Page *int64 `form:"page,omitempty" json:"page,omitempty"`
|
||||||
@@ -534,6 +541,12 @@ type LoginJSONRequestBody = LoginRequest
|
|||||||
// CreateDocumentMultipartRequestBody defines body for CreateDocument for multipart/form-data ContentType.
|
// CreateDocumentMultipartRequestBody defines body for CreateDocument for multipart/form-data ContentType.
|
||||||
type CreateDocumentMultipartRequestBody CreateDocumentMultipartBody
|
type CreateDocumentMultipartRequestBody CreateDocumentMultipartBody
|
||||||
|
|
||||||
|
// EditDocumentJSONRequestBody defines body for EditDocument for application/json ContentType.
|
||||||
|
type EditDocumentJSONRequestBody EditDocumentJSONBody
|
||||||
|
|
||||||
|
// UploadDocumentCoverMultipartRequestBody defines body for UploadDocumentCover for multipart/form-data ContentType.
|
||||||
|
type UploadDocumentCoverMultipartRequestBody UploadDocumentCoverMultipartBody
|
||||||
|
|
||||||
// PostSearchFormdataRequestBody defines body for PostSearch for application/x-www-form-urlencoded ContentType.
|
// PostSearchFormdataRequestBody defines body for PostSearch for application/x-www-form-urlencoded ContentType.
|
||||||
type PostSearchFormdataRequestBody PostSearchFormdataBody
|
type PostSearchFormdataRequestBody PostSearchFormdataBody
|
||||||
|
|
||||||
@@ -587,9 +600,15 @@ type ServerInterface interface {
|
|||||||
// Get a single document
|
// Get a single document
|
||||||
// (GET /documents/{id})
|
// (GET /documents/{id})
|
||||||
GetDocument(w http.ResponseWriter, r *http.Request, id string)
|
GetDocument(w http.ResponseWriter, r *http.Request, id string)
|
||||||
|
// Update document editable fields
|
||||||
|
// (POST /documents/{id})
|
||||||
|
EditDocument(w http.ResponseWriter, r *http.Request, id string)
|
||||||
// Get document cover image
|
// Get document cover image
|
||||||
// (GET /documents/{id}/cover)
|
// (GET /documents/{id}/cover)
|
||||||
GetDocumentCover(w http.ResponseWriter, r *http.Request, id string)
|
GetDocumentCover(w http.ResponseWriter, r *http.Request, id string)
|
||||||
|
// Upload document cover image
|
||||||
|
// (POST /documents/{id}/cover)
|
||||||
|
UploadDocumentCover(w http.ResponseWriter, r *http.Request, id string)
|
||||||
// Download document file
|
// Download document file
|
||||||
// (GET /documents/{id}/file)
|
// (GET /documents/{id}/file)
|
||||||
GetDocumentFile(w http.ResponseWriter, r *http.Request, id string)
|
GetDocumentFile(w http.ResponseWriter, r *http.Request, id string)
|
||||||
@@ -1042,6 +1061,37 @@ func (siw *ServerInterfaceWrapper) GetDocument(w http.ResponseWriter, r *http.Re
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditDocument operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) EditDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// ------------- Path parameter "id" -------------
|
||||||
|
var id string
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.EditDocument(w, r, id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// GetDocumentCover operation middleware
|
// GetDocumentCover operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) GetDocumentCover(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) GetDocumentCover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -1073,6 +1123,37 @@ func (siw *ServerInterfaceWrapper) GetDocumentCover(w http.ResponseWriter, r *ht
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadDocumentCover operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) UploadDocumentCover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// ------------- Path parameter "id" -------------
|
||||||
|
var id string
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.UploadDocumentCover(w, r, id)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// GetDocumentFile operation middleware
|
// GetDocumentFile operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) GetDocumentFile(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) GetDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -1528,7 +1609,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
|||||||
m.HandleFunc("GET "+options.BaseURL+"/documents", wrapper.GetDocuments)
|
m.HandleFunc("GET "+options.BaseURL+"/documents", wrapper.GetDocuments)
|
||||||
m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
|
m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument)
|
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument)
|
||||||
|
m.HandleFunc("POST "+options.BaseURL+"/documents/{id}", wrapper.EditDocument)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/cover", wrapper.GetDocumentCover)
|
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/cover", wrapper.GetDocumentCover)
|
||||||
|
m.HandleFunc("POST "+options.BaseURL+"/documents/{id}/cover", wrapper.UploadDocumentCover)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/file", wrapper.GetDocumentFile)
|
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/file", wrapper.GetDocumentFile)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/home", wrapper.GetHome)
|
m.HandleFunc("GET "+options.BaseURL+"/home", wrapper.GetHome)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData)
|
m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData)
|
||||||
@@ -2112,6 +2195,60 @@ func (response GetDocument500JSONResponse) VisitGetDocumentResponse(w http.Respo
|
|||||||
return json.NewEncoder(w).Encode(response)
|
return json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditDocumentRequestObject struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Body *EditDocumentJSONRequestBody
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditDocumentResponseObject interface {
|
||||||
|
VisitEditDocumentResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditDocument200JSONResponse DocumentResponse
|
||||||
|
|
||||||
|
func (response EditDocument200JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditDocument400JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response EditDocument400JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(400)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditDocument401JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response EditDocument401JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(401)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditDocument404JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response EditDocument404JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(404)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditDocument500JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response EditDocument500JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(500)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
type GetDocumentCoverRequestObject struct {
|
type GetDocumentCoverRequestObject struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
}
|
}
|
||||||
@@ -2185,6 +2322,60 @@ func (response GetDocumentCover500JSONResponse) VisitGetDocumentCoverResponse(w
|
|||||||
return json.NewEncoder(w).Encode(response)
|
return json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadDocumentCoverRequestObject struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Body *multipart.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadDocumentCoverResponseObject interface {
|
||||||
|
VisitUploadDocumentCoverResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadDocumentCover200JSONResponse DocumentResponse
|
||||||
|
|
||||||
|
func (response UploadDocumentCover200JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadDocumentCover400JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response UploadDocumentCover400JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(400)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadDocumentCover401JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response UploadDocumentCover401JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(401)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadDocumentCover404JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response UploadDocumentCover404JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(404)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadDocumentCover500JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response UploadDocumentCover500JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(500)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
type GetDocumentFileRequestObject struct {
|
type GetDocumentFileRequestObject struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
}
|
}
|
||||||
@@ -2682,9 +2873,15 @@ type StrictServerInterface interface {
|
|||||||
// Get a single document
|
// Get a single document
|
||||||
// (GET /documents/{id})
|
// (GET /documents/{id})
|
||||||
GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error)
|
GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error)
|
||||||
|
// Update document editable fields
|
||||||
|
// (POST /documents/{id})
|
||||||
|
EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error)
|
||||||
// Get document cover image
|
// Get document cover image
|
||||||
// (GET /documents/{id}/cover)
|
// (GET /documents/{id}/cover)
|
||||||
GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error)
|
GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error)
|
||||||
|
// Upload document cover image
|
||||||
|
// (POST /documents/{id}/cover)
|
||||||
|
UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error)
|
||||||
// Download document file
|
// Download document file
|
||||||
// (GET /documents/{id}/file)
|
// (GET /documents/{id}/file)
|
||||||
GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error)
|
GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error)
|
||||||
@@ -3165,6 +3362,39 @@ func (sh *strictHandler) GetDocument(w http.ResponseWriter, r *http.Request, id
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditDocument operation middleware
|
||||||
|
func (sh *strictHandler) EditDocument(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
var request EditDocumentRequestObject
|
||||||
|
|
||||||
|
request.Id = id
|
||||||
|
|
||||||
|
var body EditDocumentJSONRequestBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Body = &body
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.EditDocument(ctx, request.(EditDocumentRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "EditDocument")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(EditDocumentResponseObject); ok {
|
||||||
|
if err := validResponse.VisitEditDocumentResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDocumentCover operation middleware
|
// GetDocumentCover operation middleware
|
||||||
func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
|
func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
var request GetDocumentCoverRequestObject
|
var request GetDocumentCoverRequestObject
|
||||||
@@ -3191,6 +3421,39 @@ func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadDocumentCover operation middleware
|
||||||
|
func (sh *strictHandler) UploadDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
var request UploadDocumentCoverRequestObject
|
||||||
|
|
||||||
|
request.Id = id
|
||||||
|
|
||||||
|
if reader, err := r.MultipartReader(); err != nil {
|
||||||
|
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
request.Body = reader
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.UploadDocumentCover(ctx, request.(UploadDocumentCoverRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "UploadDocumentCover")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(UploadDocumentCoverResponseObject); ok {
|
||||||
|
if err := validResponse.VisitUploadDocumentCoverResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDocumentFile operation middleware
|
// GetDocumentFile operation middleware
|
||||||
func (sh *strictHandler) GetDocumentFile(w http.ResponseWriter, r *http.Request, id string) {
|
func (sh *strictHandler) GetDocumentFile(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
var request GetDocumentFileRequestObject
|
var request GetDocumentFileRequestObject
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
session, _ := store.Get(r, "token")
|
session, err := store.Get(r, "token")
|
||||||
|
if err != nil {
|
||||||
|
return Login401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Configure cookie options to work with Vite proxy
|
// Configure cookie options to work with Vite proxy
|
||||||
// For localhost development, we need SameSite to allow cookies across ports
|
// For localhost development, we need SameSite to allow cookies across ports
|
||||||
@@ -101,7 +104,10 @@ func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (Logou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
session, _ := store.Get(r, "token")
|
session, err := store.Get(r, "token")
|
||||||
|
if err != nil {
|
||||||
|
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Configure cookie options (same as login)
|
// Configure cookie options (same as login)
|
||||||
session.Options.SameSite = http.SameSiteLaxMode
|
session.Options.SameSite = http.SameSiteLaxMode
|
||||||
@@ -143,6 +149,15 @@ func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
|
|||||||
return auth, true
|
return auth, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAdmin checks if a user has admin privileges
|
||||||
|
func (s *Server) isAdmin(ctx context.Context) bool {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return auth.IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
// getRequestFromContext extracts the HTTP request from context
|
// getRequestFromContext extracts the HTTP request from context
|
||||||
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
|
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
|
||||||
r, ok := ctx.Value("request").(*http.Request)
|
r, ok := ctx.Value("request").(*http.Request)
|
||||||
|
|||||||
@@ -154,12 +154,111 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
|||||||
|
|
||||||
response := DocumentResponse{
|
response := DocumentResponse{
|
||||||
Document: apiDoc,
|
Document: apiDoc,
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
}
|
}
|
||||||
return GetDocument200JSONResponse(response), nil
|
return GetDocument200JSONResponse(response), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /documents/{id}
|
||||||
|
func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return EditDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return EditDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate document exists and get current state
|
||||||
|
currentDoc, err := s.db.Queries.GetDocument(ctx, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate at least one editable field is provided
|
||||||
|
if request.Body.Title == nil &&
|
||||||
|
request.Body.Author == nil &&
|
||||||
|
request.Body.Description == nil &&
|
||||||
|
request.Body.Isbn10 == nil &&
|
||||||
|
request.Body.Isbn13 == nil &&
|
||||||
|
request.Body.CoverGbid == nil {
|
||||||
|
return EditDocument400JSONResponse{Code: 400, Message: "No editable fields provided"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cover via Google Books ID
|
||||||
|
var coverFileName *string
|
||||||
|
if request.Body.CoverGbid != nil {
|
||||||
|
coverDir := filepath.Join(s.cfg.DataPath, "covers")
|
||||||
|
fileName, err := metadata.CacheCoverWithContext(ctx, *request.Body.CoverGbid, coverDir, request.Id, true)
|
||||||
|
if err == nil {
|
||||||
|
coverFileName = fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document with provided editable fields only
|
||||||
|
updatedDoc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: request.Id,
|
||||||
|
Title: request.Body.Title,
|
||||||
|
Author: request.Body.Author,
|
||||||
|
Description: request.Body.Description,
|
||||||
|
Isbn10: request.Body.Isbn10,
|
||||||
|
Isbn13: request.Body.Isbn13,
|
||||||
|
Coverfile: coverFileName,
|
||||||
|
// Preserve existing values for non-editable fields
|
||||||
|
Md5: currentDoc.Md5,
|
||||||
|
Basepath: currentDoc.Basepath,
|
||||||
|
Filepath: currentDoc.Filepath,
|
||||||
|
Words: currentDoc.Words,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
|
return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get progress for the document
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentage *float32
|
||||||
|
if progress != nil && progress.Percentage != nil {
|
||||||
|
percentage = ptrOf(float32(*progress.Percentage))
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: updatedDoc.ID,
|
||||||
|
Title: *updatedDoc.Title,
|
||||||
|
Author: *updatedDoc.Author,
|
||||||
|
Description: updatedDoc.Description,
|
||||||
|
Isbn10: updatedDoc.Isbn10,
|
||||||
|
Isbn13: updatedDoc.Isbn13,
|
||||||
|
Words: updatedDoc.Words,
|
||||||
|
Filepath: updatedDoc.Filepath,
|
||||||
|
CreatedAt: parseTime(updatedDoc.CreatedAt),
|
||||||
|
UpdatedAt: parseTime(updatedDoc.UpdatedAt),
|
||||||
|
Deleted: updatedDoc.Deleted,
|
||||||
|
Percentage: percentage,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
Progress: progress,
|
||||||
|
}
|
||||||
|
return EditDocument200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
|
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
|
||||||
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
||||||
// Derive New FileName
|
// Derive New FileName
|
||||||
@@ -296,8 +395,12 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
|
|||||||
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
|
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
|
||||||
|
|
||||||
if needMetadataFetch {
|
if needMetadataFetch {
|
||||||
|
// Create context with timeout for metadata service calls
|
||||||
|
metadataCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Identify Documents & Save Covers
|
// Identify Documents & Save Covers
|
||||||
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
metadataResults, err := metadata.SearchMetadataWithContext(metadataCtx, metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
||||||
Title: document.Title,
|
Title: document.Title,
|
||||||
Author: document.Author,
|
Author: document.Author,
|
||||||
})
|
})
|
||||||
@@ -306,7 +409,7 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
|
|||||||
firstResult := metadataResults[0]
|
firstResult := metadataResults[0]
|
||||||
|
|
||||||
// Save Cover
|
// Save Cover
|
||||||
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
|
fileName, err := metadata.CacheCoverWithContext(metadataCtx, *firstResult.ID, coverDir, document.ID, false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
cachedCoverFile = *fileName
|
cachedCoverFile = *fileName
|
||||||
}
|
}
|
||||||
@@ -368,6 +471,136 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /documents/{id}/cover
|
||||||
|
func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return UploadDocumentCover401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate document exists
|
||||||
|
_, err := s.db.Queries.GetDocument(ctx, request.Id)
|
||||||
|
if err != nil {
|
||||||
|
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read multipart form
|
||||||
|
form, err := request.Body.ReadForm(32 << 20) // 32MB max
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ReadForm error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from form
|
||||||
|
fileField := form.File["cover_file"]
|
||||||
|
if len(fileField) == 0 {
|
||||||
|
return UploadDocumentCover400JSONResponse{Code: 400, Message: "No file provided"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file := fileField[0]
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
if !strings.HasSuffix(strings.ToLower(file.Filename), ".jpg") && !strings.HasSuffix(strings.ToLower(file.Filename), ".png") {
|
||||||
|
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Only JPG and PNG files are allowed"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Open file error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Read file error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate actual content type
|
||||||
|
contentType := http.DetectContentType(data)
|
||||||
|
allowedTypes := map[string]bool{
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
}
|
||||||
|
if !allowedTypes[contentType] {
|
||||||
|
return UploadDocumentCover400JSONResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: fmt.Sprintf("Invalid file type: %s. Only JPG and PNG files are allowed.", contentType),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive storage path
|
||||||
|
coverDir := filepath.Join(s.cfg.DataPath, "covers")
|
||||||
|
fileName := fmt.Sprintf("%s%s", request.Id, strings.ToLower(filepath.Ext(file.Filename)))
|
||||||
|
safePath := filepath.Join(coverDir, fileName)
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
err = os.WriteFile(safePath, data, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Save file error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Unable to save cover"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert document with new cover
|
||||||
|
updatedDoc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: request.Id,
|
||||||
|
Coverfile: &fileName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpsertDocument DB error:", err)
|
||||||
|
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get progress for the document
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentage *float32
|
||||||
|
if progress != nil && progress.Percentage != nil {
|
||||||
|
percentage = ptrOf(float32(*progress.Percentage))
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: updatedDoc.ID,
|
||||||
|
Title: *updatedDoc.Title,
|
||||||
|
Author: *updatedDoc.Author,
|
||||||
|
Description: updatedDoc.Description,
|
||||||
|
Isbn10: updatedDoc.Isbn10,
|
||||||
|
Isbn13: updatedDoc.Isbn13,
|
||||||
|
Words: updatedDoc.Words,
|
||||||
|
Filepath: updatedDoc.Filepath,
|
||||||
|
CreatedAt: parseTime(updatedDoc.CreatedAt),
|
||||||
|
UpdatedAt: parseTime(updatedDoc.UpdatedAt),
|
||||||
|
Deleted: updatedDoc.Deleted,
|
||||||
|
Percentage: percentage,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
Progress: progress,
|
||||||
|
}
|
||||||
|
return UploadDocumentCover200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GET /documents/{id}/file
|
// GET /documents/{id}/file
|
||||||
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
|
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
|
||||||
// Authentication is handled by middleware, which also adds auth data to context
|
// Authentication is handled by middleware, which also adds auth data to context
|
||||||
@@ -416,7 +649,7 @@ func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileReq
|
|||||||
|
|
||||||
// POST /documents
|
// POST /documents
|
||||||
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
|
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
_, ok := s.getSessionFromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
}
|
}
|
||||||
@@ -460,6 +693,15 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
|||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate actual content type
|
||||||
|
contentType := http.DetectContentType(data)
|
||||||
|
if contentType != "application/epub+zip" && contentType != "application/zip" {
|
||||||
|
return CreateDocument400JSONResponse{
|
||||||
|
Code: 400,
|
||||||
|
Message: fmt.Sprintf("Invalid file type: %s. Only EPUB files are allowed.", contentType),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create temp file to get metadata
|
// Create temp file to get metadata
|
||||||
tempFile, err := os.CreateTemp("", "book")
|
tempFile, err := os.CreateTemp("", "book")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -502,7 +744,6 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
|||||||
}
|
}
|
||||||
response := DocumentResponse{
|
response := DocumentResponse{
|
||||||
Document: apiDoc,
|
Document: apiDoc,
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
}
|
}
|
||||||
return CreateDocument200JSONResponse(response), nil
|
return CreateDocument200JSONResponse(response), nil
|
||||||
}
|
}
|
||||||
@@ -551,7 +792,6 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
|||||||
|
|
||||||
response := DocumentResponse{
|
response := DocumentResponse{
|
||||||
Document: apiDoc,
|
Document: apiDoc,
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateDocument200JSONResponse(response), nil
|
return CreateDocument200JSONResponse(response), nil
|
||||||
|
|||||||
@@ -54,23 +54,11 @@ func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (Get
|
|||||||
},
|
},
|
||||||
Streaks: StreaksResponse{
|
Streaks: StreaksResponse{
|
||||||
Streaks: convertStreaks(streaks),
|
Streaks: convertStreaks(streaks),
|
||||||
User: UserData{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
GraphData: GraphDataResponse{
|
GraphData: GraphDataResponse{
|
||||||
GraphData: convertGraphData(graphData),
|
GraphData: convertGraphData(graphData),
|
||||||
User: UserData{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
UserStatistics: arrangeUserStatistics(userStats),
|
UserStatistics: arrangeUserStatistics(userStats),
|
||||||
User: UserData{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return GetHome200JSONResponse(response), nil
|
return GetHome200JSONResponse(response), nil
|
||||||
@@ -91,10 +79,6 @@ func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject
|
|||||||
|
|
||||||
response := StreaksResponse{
|
response := StreaksResponse{
|
||||||
Streaks: convertStreaks(streaks),
|
Streaks: convertStreaks(streaks),
|
||||||
User: UserData{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return GetStreaks200JSONResponse(response), nil
|
return GetStreaks200JSONResponse(response), nil
|
||||||
@@ -115,10 +99,6 @@ func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestOb
|
|||||||
|
|
||||||
response := GraphDataResponse{
|
response := GraphDataResponse{
|
||||||
GraphData: convertGraphData(graphData),
|
GraphData: convertGraphData(graphData),
|
||||||
User: UserData{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return GetGraphData200JSONResponse(response), nil
|
return GetGraphData200JSONResponse(response), nil
|
||||||
@@ -126,7 +106,7 @@ func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestOb
|
|||||||
|
|
||||||
// GET /home/statistics
|
// GET /home/statistics
|
||||||
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
|
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
_, ok := s.getSessionFromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
}
|
}
|
||||||
@@ -138,11 +118,6 @@ func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatistic
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := arrangeUserStatistics(userStats)
|
response := arrangeUserStatistics(userStats)
|
||||||
response.User = UserData{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetUserStatistics200JSONResponse(response), nil
|
return GetUserStatistics200JSONResponse(response), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,13 +233,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
document:
|
document:
|
||||||
$ref: '#/components/schemas/Document'
|
$ref: '#/components/schemas/Document'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
progress:
|
progress:
|
||||||
$ref: '#/components/schemas/Progress'
|
$ref: '#/components/schemas/Progress'
|
||||||
required:
|
required:
|
||||||
- document
|
- document
|
||||||
- user
|
|
||||||
|
|
||||||
ProgressListResponse:
|
ProgressListResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -248,8 +245,6 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Progress'
|
$ref: '#/components/schemas/Progress'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
page:
|
page:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
@@ -271,8 +266,6 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
progress:
|
progress:
|
||||||
$ref: '#/components/schemas/Progress'
|
$ref: '#/components/schemas/Progress'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
|
|
||||||
ActivityResponse:
|
ActivityResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -281,11 +274,8 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Activity'
|
$ref: '#/components/schemas/Activity'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
required:
|
required:
|
||||||
- activities
|
- activities
|
||||||
- user
|
|
||||||
|
|
||||||
Device:
|
Device:
|
||||||
type: object
|
type: object
|
||||||
@@ -423,11 +413,8 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/UserStreak'
|
$ref: '#/components/schemas/UserStreak'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
required:
|
required:
|
||||||
- streaks
|
- streaks
|
||||||
- user
|
|
||||||
|
|
||||||
GraphDataPoint:
|
GraphDataPoint:
|
||||||
type: object
|
type: object
|
||||||
@@ -448,11 +435,8 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/GraphDataPoint'
|
$ref: '#/components/schemas/GraphDataPoint'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
required:
|
required:
|
||||||
- graph_data
|
- graph_data
|
||||||
- user
|
|
||||||
|
|
||||||
LeaderboardEntry:
|
LeaderboardEntry:
|
||||||
type: object
|
type: object
|
||||||
@@ -500,13 +484,10 @@ components:
|
|||||||
$ref: '#/components/schemas/LeaderboardData'
|
$ref: '#/components/schemas/LeaderboardData'
|
||||||
words:
|
words:
|
||||||
$ref: '#/components/schemas/LeaderboardData'
|
$ref: '#/components/schemas/LeaderboardData'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
required:
|
required:
|
||||||
- wpm
|
- wpm
|
||||||
- duration
|
- duration
|
||||||
- words
|
- words
|
||||||
- user
|
|
||||||
|
|
||||||
HomeResponse:
|
HomeResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -519,14 +500,11 @@ components:
|
|||||||
$ref: '#/components/schemas/GraphDataResponse'
|
$ref: '#/components/schemas/GraphDataResponse'
|
||||||
user_statistics:
|
user_statistics:
|
||||||
$ref: '#/components/schemas/UserStatisticsResponse'
|
$ref: '#/components/schemas/UserStatisticsResponse'
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/UserData'
|
|
||||||
required:
|
required:
|
||||||
- database_info
|
- database_info
|
||||||
- streaks
|
- streaks
|
||||||
- graph_data
|
- graph_data
|
||||||
- user_statistics
|
- user_statistics
|
||||||
- user
|
|
||||||
|
|
||||||
BackupType:
|
BackupType:
|
||||||
type: string
|
type: string
|
||||||
@@ -765,6 +743,69 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
post:
|
||||||
|
summary: Update document editable fields
|
||||||
|
operationId: editDocument
|
||||||
|
tags:
|
||||||
|
- Documents
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
author:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
isbn10:
|
||||||
|
type: string
|
||||||
|
isbn13:
|
||||||
|
type: string
|
||||||
|
cover_gbid:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DocumentResponse'
|
||||||
|
400:
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
404:
|
||||||
|
description: Document not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/documents/{id}/cover:
|
/documents/{id}/cover:
|
||||||
get:
|
get:
|
||||||
@@ -804,6 +845,62 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
post:
|
||||||
|
summary: Upload document cover image
|
||||||
|
operationId: uploadDocumentCover
|
||||||
|
tags:
|
||||||
|
- Documents
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
cover_file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
required:
|
||||||
|
- cover_file
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Cover uploaded
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DocumentResponse'
|
||||||
|
400:
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
404:
|
||||||
|
description: Document not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/documents/{id}/file:
|
/documents/{id}/file:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq
|
|||||||
|
|
||||||
response := ProgressListResponse{
|
response := ProgressListResponse{
|
||||||
Progress: &apiProgress,
|
Progress: &apiProgress,
|
||||||
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
Page: &page,
|
Page: &page,
|
||||||
Limit: &limit,
|
Limit: &limit,
|
||||||
NextPage: nextPage,
|
NextPage: nextPage,
|
||||||
@@ -119,7 +118,6 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
|||||||
|
|
||||||
response := ProgressResponse{
|
response := ProgressResponse{
|
||||||
Progress: &apiProgress,
|
Progress: &apiProgress,
|
||||||
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return GetProgress200JSONResponse(response), nil
|
return GetProgress200JSONResponse(response), nil
|
||||||
|
|||||||
@@ -60,6 +60,28 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check admin status for admin-only endpoints
|
||||||
|
adminEndpoints := []string{
|
||||||
|
"GetAdmin",
|
||||||
|
"PostAdminAction",
|
||||||
|
"GetUsers",
|
||||||
|
"UpdateUser",
|
||||||
|
"GetImportDirectory",
|
||||||
|
"PostImport",
|
||||||
|
"GetImportResults",
|
||||||
|
"GetLogs",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, adminEndpoint := range adminEndpoints {
|
||||||
|
if operationID == adminEndpoint && !auth.IsAdmin {
|
||||||
|
// Write 403 response directly
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(403)
|
||||||
|
json.NewEncoder(w).Encode(ErrorResponse{Code: 403, Message: "Admin privileges required"})
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store auth in context for handlers to access
|
// Store auth in context for handlers to access
|
||||||
ctx = context.WithValue(ctx, "auth", auth)
|
ctx = context.WithValue(ctx, "auth", auth)
|
||||||
|
|
||||||
|
|||||||
@@ -40,15 +40,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} else if (meData?.data && meData.status === 200) {
|
} else if (meData?.data && meData.status === 200) {
|
||||||
// User is authenticated - check that response has valid data
|
// User is authenticated - check that response has valid data
|
||||||
console.log('[AuthContext] User authenticated:', meData.data);
|
console.log('[AuthContext] User authenticated:', meData.data);
|
||||||
|
const userData = 'username' in meData.data ? meData.data : null;
|
||||||
return {
|
return {
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user: meData.data,
|
user: userData as { username: string; is_admin: boolean } | null,
|
||||||
isCheckingAuth: false,
|
isCheckingAuth: false,
|
||||||
};
|
};
|
||||||
} else if (
|
} else if (
|
||||||
meError ||
|
meError ||
|
||||||
(meData && meData.status === 401) ||
|
(meData && meData.status === 401)
|
||||||
(meData && meData.status === 403)
|
|
||||||
) {
|
) {
|
||||||
// User is not authenticated or error occurred
|
// User is not authenticated or error occurred
|
||||||
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
|
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
|
||||||
@@ -77,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// The session cookie is automatically set by the browser
|
// The session cookie is automatically set by the browser
|
||||||
setAuthState({
|
setAuthState({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user: response.data,
|
user: 'username' in response.data ? response.data as { username: string; is_admin: boolean } : null,
|
||||||
isCheckingAuth: false,
|
isCheckingAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Home, FileText, Activity, Search, Settings } from 'lucide-react';
|
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
@@ -12,18 +12,18 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ path: '/', label: 'Home', icon: Home, title: 'Home' },
|
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
|
||||||
{ path: '/documents', label: 'Documents', icon: FileText, title: 'Documents' },
|
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
|
||||||
{ path: '/progress', label: 'Progress', icon: Activity, title: 'Progress' },
|
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
|
||||||
{ path: '/activity', label: 'Activity', icon: Activity, title: 'Activity' },
|
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
|
||||||
{ path: '/search', label: 'Search', icon: Search, title: 'Search' },
|
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminSubItems: NavItem[] = [
|
const adminSubItems: NavItem[] = [
|
||||||
{ path: '/admin', label: 'General', icon: Settings, title: 'General' },
|
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
|
||||||
{ path: '/admin/import', label: 'Import', icon: Settings, title: 'Import' },
|
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
|
||||||
{ path: '/admin/users', label: 'Users', icon: Settings, title: 'Users' },
|
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
|
||||||
{ path: '/admin/logs', label: 'Logs', icon: Settings, title: 'Logs' },
|
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper function to check if pathname has a prefix
|
// Helper function to check if pathname has a prefix
|
||||||
@@ -152,7 +152,7 @@ export default function HamburgerMenu() {
|
|||||||
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Settings size={20} />
|
<SettingsIcon size={20} />
|
||||||
<span className="mx-4 text-sm font-normal">Admin</span>
|
<span className="mx-4 text-sm font-normal">Admin</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { User, ChevronDown } from 'lucide-react';
|
import { UserIcon } from '../icons';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import HamburgerMenu from './HamburgerMenu';
|
import HamburgerMenu from './HamburgerMenu';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -73,7 +74,7 @@ export default function Layout() {
|
|||||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||||
className="relative block text-gray-800 dark:text-gray-200"
|
className="relative block text-gray-800 dark:text-gray-200"
|
||||||
>
|
>
|
||||||
<User size={20} />
|
<UserIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isUserDropdownOpen && (
|
{isUserDropdownOpen && (
|
||||||
@@ -113,7 +114,7 @@ export default function Layout() {
|
|||||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||||
className="flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white"
|
className="flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white"
|
||||||
>
|
>
|
||||||
<span>{userData?.username || 'User'}</span>
|
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
|
||||||
<span
|
<span
|
||||||
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
|
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
|
||||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
DirectoryListResponse,
|
DirectoryListResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
DocumentsResponse,
|
DocumentsResponse,
|
||||||
|
EditDocumentBody,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
GetActivityParams,
|
GetActivityParams,
|
||||||
GetAdmin200,
|
GetAdmin200,
|
||||||
@@ -56,6 +57,7 @@ import type {
|
|||||||
StreaksResponse,
|
StreaksResponse,
|
||||||
UpdateSettingsRequest,
|
UpdateSettingsRequest,
|
||||||
UpdateUserBody,
|
UpdateUserBody,
|
||||||
|
UploadDocumentCoverBody,
|
||||||
UserStatisticsResponse,
|
UserStatisticsResponse,
|
||||||
UsersResponse
|
UsersResponse
|
||||||
} from './model';
|
} from './model';
|
||||||
@@ -442,6 +444,118 @@ export function useGetDocument<TData = Awaited<ReturnType<typeof getDocument>>,
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update document editable fields
|
||||||
|
*/
|
||||||
|
export type editDocumentResponse200 = {
|
||||||
|
data: DocumentResponse
|
||||||
|
status: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
export type editDocumentResponse400 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 400
|
||||||
|
}
|
||||||
|
|
||||||
|
export type editDocumentResponse401 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 401
|
||||||
|
}
|
||||||
|
|
||||||
|
export type editDocumentResponse404 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 404
|
||||||
|
}
|
||||||
|
|
||||||
|
export type editDocumentResponse500 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 500
|
||||||
|
}
|
||||||
|
|
||||||
|
export type editDocumentResponseSuccess = (editDocumentResponse200) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type editDocumentResponseError = (editDocumentResponse400 | editDocumentResponse401 | editDocumentResponse404 | editDocumentResponse500) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type editDocumentResponse = (editDocumentResponseSuccess | editDocumentResponseError)
|
||||||
|
|
||||||
|
export const getEditDocumentUrl = (id: string,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/v1/documents/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editDocument = async (id: string,
|
||||||
|
editDocumentBody: EditDocumentBody, options?: RequestInit): Promise<editDocumentResponse> => {
|
||||||
|
|
||||||
|
const res = await fetch(getEditDocumentUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
editDocumentBody,)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||||
|
|
||||||
|
const data: editDocumentResponse['data'] = body ? JSON.parse(body) : {}
|
||||||
|
return { data, status: res.status, headers: res.headers } as editDocumentResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getEditDocumentMutationOptions = <TError = ErrorResponse,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof editDocument>>, TError,{id: string;data: EditDocumentBody}, TContext>, fetch?: RequestInit}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof editDocument>>, TError,{id: string;data: EditDocumentBody}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['editDocument'];
|
||||||
|
const {mutation: mutationOptions, fetch: fetchOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, fetch: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof editDocument>>, {id: string;data: EditDocumentBody}> = (props) => {
|
||||||
|
const {id,data} = props ?? {};
|
||||||
|
|
||||||
|
return editDocument(id,data,fetchOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type EditDocumentMutationResult = NonNullable<Awaited<ReturnType<typeof editDocument>>>
|
||||||
|
export type EditDocumentMutationBody = EditDocumentBody
|
||||||
|
export type EditDocumentMutationError = ErrorResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update document editable fields
|
||||||
|
*/
|
||||||
|
export const useEditDocument = <TError = ErrorResponse,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof editDocument>>, TError,{id: string;data: EditDocumentBody}, TContext>, fetch?: RequestInit}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof editDocument>>,
|
||||||
|
TError,
|
||||||
|
{id: string;data: EditDocumentBody},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getEditDocumentMutationOptions(options), queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get document cover image
|
* @summary Get document cover image
|
||||||
*/
|
*/
|
||||||
@@ -581,6 +695,120 @@ export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumen
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Upload document cover image
|
||||||
|
*/
|
||||||
|
export type uploadDocumentCoverResponse200 = {
|
||||||
|
data: DocumentResponse
|
||||||
|
status: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
export type uploadDocumentCoverResponse400 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 400
|
||||||
|
}
|
||||||
|
|
||||||
|
export type uploadDocumentCoverResponse401 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 401
|
||||||
|
}
|
||||||
|
|
||||||
|
export type uploadDocumentCoverResponse404 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 404
|
||||||
|
}
|
||||||
|
|
||||||
|
export type uploadDocumentCoverResponse500 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 500
|
||||||
|
}
|
||||||
|
|
||||||
|
export type uploadDocumentCoverResponseSuccess = (uploadDocumentCoverResponse200) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type uploadDocumentCoverResponseError = (uploadDocumentCoverResponse400 | uploadDocumentCoverResponse401 | uploadDocumentCoverResponse404 | uploadDocumentCoverResponse500) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type uploadDocumentCoverResponse = (uploadDocumentCoverResponseSuccess | uploadDocumentCoverResponseError)
|
||||||
|
|
||||||
|
export const getUploadDocumentCoverUrl = (id: string,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/v1/documents/${id}/cover`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadDocumentCover = async (id: string,
|
||||||
|
uploadDocumentCoverBody: UploadDocumentCoverBody, options?: RequestInit): Promise<uploadDocumentCoverResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(`cover_file`, uploadDocumentCoverBody.cover_file);
|
||||||
|
|
||||||
|
const res = await fetch(getUploadDocumentCoverUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST'
|
||||||
|
,
|
||||||
|
body:
|
||||||
|
formData,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||||
|
|
||||||
|
const data: uploadDocumentCoverResponse['data'] = body ? JSON.parse(body) : {}
|
||||||
|
return { data, status: res.status, headers: res.headers } as uploadDocumentCoverResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getUploadDocumentCoverMutationOptions = <TError = ErrorResponse,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof uploadDocumentCover>>, TError,{id: string;data: UploadDocumentCoverBody}, TContext>, fetch?: RequestInit}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof uploadDocumentCover>>, TError,{id: string;data: UploadDocumentCoverBody}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['uploadDocumentCover'];
|
||||||
|
const {mutation: mutationOptions, fetch: fetchOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, fetch: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof uploadDocumentCover>>, {id: string;data: UploadDocumentCoverBody}> = (props) => {
|
||||||
|
const {id,data} = props ?? {};
|
||||||
|
|
||||||
|
return uploadDocumentCover(id,data,fetchOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type UploadDocumentCoverMutationResult = NonNullable<Awaited<ReturnType<typeof uploadDocumentCover>>>
|
||||||
|
export type UploadDocumentCoverMutationBody = UploadDocumentCoverBody
|
||||||
|
export type UploadDocumentCoverMutationError = ErrorResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Upload document cover image
|
||||||
|
*/
|
||||||
|
export const useUploadDocumentCover = <TError = ErrorResponse,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof uploadDocumentCover>>, TError,{id: string;data: UploadDocumentCoverBody}, TContext>, fetch?: RequestInit}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof uploadDocumentCover>>,
|
||||||
|
TError,
|
||||||
|
{id: string;data: UploadDocumentCoverBody},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getUploadDocumentCoverMutationOptions(options), queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Download document file
|
* @summary Download document file
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { Activity } from './activity';
|
import type { Activity } from './activity';
|
||||||
import type { UserData } from './userData';
|
|
||||||
|
|
||||||
export interface ActivityResponse {
|
export interface ActivityResponse {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
user: UserData;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
import type { Document } from './document';
|
import type { Document } from './document';
|
||||||
import type { Progress } from './progress';
|
import type { Progress } from './progress';
|
||||||
import type { UserData } from './userData';
|
|
||||||
|
|
||||||
export interface DocumentResponse {
|
export interface DocumentResponse {
|
||||||
document: Document;
|
document: Document;
|
||||||
user: UserData;
|
|
||||||
progress?: Progress;
|
progress?: Progress;
|
||||||
}
|
}
|
||||||
|
|||||||
16
frontend/src/generated/model/editDocumentBody.ts
Normal file
16
frontend/src/generated/model/editDocumentBody.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EditDocumentBody = {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
description?: string;
|
||||||
|
isbn10?: string;
|
||||||
|
isbn13?: string;
|
||||||
|
cover_gbid?: string;
|
||||||
|
};
|
||||||
@@ -6,9 +6,7 @@
|
|||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { GraphDataPoint } from './graphDataPoint';
|
import type { GraphDataPoint } from './graphDataPoint';
|
||||||
import type { UserData } from './userData';
|
|
||||||
|
|
||||||
export interface GraphDataResponse {
|
export interface GraphDataResponse {
|
||||||
graph_data: GraphDataPoint[];
|
graph_data: GraphDataPoint[];
|
||||||
user: UserData;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import type { DatabaseInfo } from './databaseInfo';
|
import type { DatabaseInfo } from './databaseInfo';
|
||||||
import type { GraphDataResponse } from './graphDataResponse';
|
import type { GraphDataResponse } from './graphDataResponse';
|
||||||
import type { StreaksResponse } from './streaksResponse';
|
import type { StreaksResponse } from './streaksResponse';
|
||||||
import type { UserData } from './userData';
|
|
||||||
import type { UserStatisticsResponse } from './userStatisticsResponse';
|
import type { UserStatisticsResponse } from './userStatisticsResponse';
|
||||||
|
|
||||||
export interface HomeResponse {
|
export interface HomeResponse {
|
||||||
@@ -16,5 +15,4 @@ export interface HomeResponse {
|
|||||||
streaks: StreaksResponse;
|
streaks: StreaksResponse;
|
||||||
graph_data: GraphDataResponse;
|
graph_data: GraphDataResponse;
|
||||||
user_statistics: UserStatisticsResponse;
|
user_statistics: UserStatisticsResponse;
|
||||||
user: UserData;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export * from './directoryListResponse';
|
|||||||
export * from './document';
|
export * from './document';
|
||||||
export * from './documentResponse';
|
export * from './documentResponse';
|
||||||
export * from './documentsResponse';
|
export * from './documentsResponse';
|
||||||
|
export * from './editDocumentBody';
|
||||||
export * from './errorResponse';
|
export * from './errorResponse';
|
||||||
export * from './getActivityParams';
|
export * from './getActivityParams';
|
||||||
export * from './getAdmin200';
|
export * from './getAdmin200';
|
||||||
@@ -55,8 +56,10 @@ export * from './searchResponse';
|
|||||||
export * from './setting';
|
export * from './setting';
|
||||||
export * from './settingsResponse';
|
export * from './settingsResponse';
|
||||||
export * from './streaksResponse';
|
export * from './streaksResponse';
|
||||||
|
export * from './updateDocumentBody';
|
||||||
export * from './updateSettingsRequest';
|
export * from './updateSettingsRequest';
|
||||||
export * from './updateUserBody';
|
export * from './updateUserBody';
|
||||||
|
export * from './uploadDocumentCoverBody';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './userData';
|
export * from './userData';
|
||||||
export * from './usersResponse';
|
export * from './usersResponse';
|
||||||
|
|||||||
@@ -6,11 +6,9 @@
|
|||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { Progress } from './progress';
|
import type { Progress } from './progress';
|
||||||
import type { UserData } from './userData';
|
|
||||||
|
|
||||||
export interface ProgressListResponse {
|
export interface ProgressListResponse {
|
||||||
progress?: Progress[];
|
progress?: Progress[];
|
||||||
user?: UserData;
|
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
next_page?: number;
|
next_page?: number;
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { Progress } from './progress';
|
import type { Progress } from './progress';
|
||||||
import type { UserData } from './userData';
|
|
||||||
|
|
||||||
export interface ProgressResponse {
|
export interface ProgressResponse {
|
||||||
progress?: Progress;
|
progress?: Progress;
|
||||||
user?: UserData;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
* REST API for AnthoLume document management system
|
* REST API for AnthoLume document management system
|
||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { UserData } from './userData';
|
|
||||||
import type { UserStreak } from './userStreak';
|
import type { UserStreak } from './userStreak';
|
||||||
|
|
||||||
export interface StreaksResponse {
|
export interface StreaksResponse {
|
||||||
streaks: UserStreak[];
|
streaks: UserStreak[];
|
||||||
user: UserData;
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend/src/generated/model/updateDocumentBody.ts
Normal file
15
frontend/src/generated/model/updateDocumentBody.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UpdateDocumentBody = {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
description?: string;
|
||||||
|
isbn10?: string;
|
||||||
|
isbn13?: string;
|
||||||
|
};
|
||||||
11
frontend/src/generated/model/uploadDocumentCoverBody.ts
Normal file
11
frontend/src/generated/model/uploadDocumentCoverBody.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UploadDocumentCoverBody = {
|
||||||
|
cover_file: Blob;
|
||||||
|
};
|
||||||
@@ -6,11 +6,9 @@
|
|||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { LeaderboardData } from './leaderboardData';
|
import type { LeaderboardData } from './leaderboardData';
|
||||||
import type { UserData } from './userData';
|
|
||||||
|
|
||||||
export interface UserStatisticsResponse {
|
export interface UserStatisticsResponse {
|
||||||
wpm: LeaderboardData;
|
wpm: LeaderboardData;
|
||||||
duration: LeaderboardData;
|
duration: LeaderboardData;
|
||||||
words: LeaderboardData;
|
words: LeaderboardData;
|
||||||
user: UserData;
|
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/src/icons/ActivityIcon.tsx
Normal file
20
frontend/src/icons/ActivityIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface ActivityIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityIcon({ size = 24, className = '', disabled = false }: ActivityIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path d="M9.5 2C8.67157 2 8 2.67157 8 3.5V4.5C8 5.32843 8.67157 6 9.5 6H14.5C15.3284 6 16 5.32843 16 4.5V3.5C16 2.67157 15.3284 2 14.5 2H9.5Z" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M6.5 4.03662C5.24209 4.10719 4.44798 4.30764 3.87868 4.87694C3 5.75562 3 7.16983 3 9.99826V15.9983C3 18.8267 3 20.2409 3.87868 21.1196C4.75736 21.9983 6.17157 21.9983 9 21.9983H15C17.8284 21.9983 19.2426 21.9983 20.1213 21.1196C21 20.2409 21 18.8267 21 15.9983V9.99826C21 7.16983 21 5.75562 20.1213 4.87694C19.552 4.30764 18.7579 4.10719 17.5 4.03662V4.5C17.5 6.15685 16.1569 7.5 14.5 7.5H9.5C7.84315 7.5 6.5 6.15685 6.5 4.5V4.03662ZM7 9.75C6.58579 9.75 6.25 10.0858 6.25 10.5C6.25 10.9142 6.58579 11.25 7 11.25H7.5C7.91421 11.25 8.25 10.9142 8.25 10.5C8.25 10.0858 7.91421 9.75 7.5 9.75H7ZM10.5 9.75C10.0858 9.75 9.75 10.0858 9.75 10.5C9.75 10.9142 10.0858 11.25 10.5 11.25H17C17.4142 11.25 17.75 10.9142 17.75 10.5C17.75 10.0858 17.4142 9.75 17 9.75H10.5ZM7 13.25C6.58579 13.25 6.25 13.5858 6.25 14C6.25 14.4142 6.58579 14.75 7 14.75H7.5C7.91421 14.75 8.25 14.4142 8.25 14C8.25 13.5858 7.91421 13.25 7.5 13.25H7ZM10.5 13.25C10.0858 13.25 9.75 13.5858 9.75 14C9.75 14.4142 10.0858 14.75 10.5 14.75H17C17.4142 14.75 17.75 14.4142 17.75 14C17.75 13.5858 17.4142 13.25 17 13.25H10.5ZM7 16.75C6.58579 16.75 6.25 17.0858 6.25 17.5C6.25 17.9142 6.58579 18.25 7 18.25H7.5C7.91421 18.25 8.25 17.9142 8.25 17.5C8.25 17.0858 7.91421 16.75 7.5 16.75H7ZM10.5 16.75C10.0858 16.75 9.75 17.0858 9.75 17.5C9.75 17.9142 10.0858 18.25 10.5 18.25H17C17.4142 18.25 17.75 17.9142 17.75 17.5C17.75 17.0858 17.4142 16.75 17 16.75H10.5Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/AddIcon.tsx
Normal file
19
frontend/src/icons/AddIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface AddIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddIcon({ size = 24, className = '', disabled = false }: AddIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM12 8.25C12.4142 8.25 12.75 8.58579 12.75 9V11.25H15C15.4142 11.25 15.75 11.5858 15.75 12C15.75 12.4142 15.4142 12.75 15 12.75H12.75L12.75 15C12.75 15.4142 12.4142 15.75 12 15.75C11.5858 15.75 11.25 15.4142 11.25 15V12.75H9C8.58579 12.75 8.25 12.4142 8.25 12C8.25 11.5858 8.58579 11.25 9 11.25H11.25L11.25 9C11.25 8.58579 11.5858 8.25 12 8.25Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/icons/BaseIcon.tsx
Normal file
34
frontend/src/icons/BaseIcon.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface BaseIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
viewBox?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseIcon({
|
||||||
|
size = 24,
|
||||||
|
className = '',
|
||||||
|
disabled = false,
|
||||||
|
viewBox = '0 0 24 24',
|
||||||
|
children,
|
||||||
|
}: BaseIconProps) {
|
||||||
|
const disabledClasses = disabled
|
||||||
|
? 'text-gray-200 dark:text-gray-600'
|
||||||
|
: 'hover:text-gray-800 dark:hover:text-gray-100';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={viewBox}
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`${disabledClasses} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/icons/ClockIcon.tsx
Normal file
21
frontend/src/icons/ClockIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface ClockIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockIcon({ size = 24, className = '', disabled = false }: ClockIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/icons/DeleteIcon.tsx
Normal file
16
frontend/src/icons/DeleteIcon.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface DeleteIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteIcon({ size = 24, className = '', disabled = false }: DeleteIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path d="M3 6.52381C3 6.12932 3.32671 5.80952 3.72973 5.80952H8.51787C8.52437 4.9683 8.61554 3.81504 9.45037 3.01668C10.1074 2.38839 11.0081 2 12 2C12.9919 2 13.8926 2.38839 14.5496 3.01668C15.3844 3.81504 15.4756 4.9683 15.4821 5.80952H20.2703C20.6733 5.80952 21 6.12932 21 6.52381C21 6.9183 20.6733 7.2381 20.2703 7.2381H3.72973C3.32671 7.2381 3 6.9183 3 6.52381Z" />
|
||||||
|
<path d="M11.6066 22H12.3935C15.101 22 16.4547 22 17.3349 21.1368C18.2151 20.2736 18.3052 18.8576 18.4853 16.0257L18.7448 11.9452C18.8425 10.4086 18.8913 9.64037 18.4498 9.15352C18.0082 8.66667 17.2625 8.66667 15.7712 8.66667H8.22884C6.7375 8.66667 5.99183 8.66667 5.55026 9.15352C5.1087 9.64037 5.15756 10.4086 5.25528 11.9452L5.51479 16.0257C5.69489 18.8576 5.78494 20.2736 6.66513 21.1368C7.54532 22 8.89906 22 11.6066 22Z" />
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/icons/DocumentsIcon.tsx
Normal file
20
frontend/src/icons/DocumentsIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface DocumentsIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsIcon({ size = 24, className = '', disabled = false }: DocumentsIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M6.27103 2.11151C5.46135 2.21816 5.03258 2.41324 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.82716 15.513 6.44305 15.5132 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.2092 2 7.10452 2.00172 6.27103 2.11151ZM6.75862 6.59459C6.75862 6.1468 7.12914 5.78378 7.58621 5.78378H16.4138C16.8709 5.78378 17.2414 6.1468 17.2414 6.59459C17.2414 7.04239 16.8709 7.40541 16.4138 7.40541H7.58621C7.12914 7.40541 6.75862 7.04239 6.75862 6.59459ZM7.58621 9.56757C7.12914 9.56757 6.75862 9.93058 6.75862 10.3784C6.75862 10.8262 7.12914 11.1892 7.58621 11.1892H13.1034C13.5605 11.1892 13.931 10.8262 13.931 10.3784C13.931 9.93058 13.5605 9.56757 13.1034 9.56757H7.58621Z"
|
||||||
|
/>
|
||||||
|
<path d="M7.47341 17.1351H8.68965H13.1034H19.9991C19.9956 18.2657 19.9776 19.1088 19.8862 19.775C19.7773 20.5683 19.5782 20.9884 19.2728 21.2876C18.9674 21.5868 18.5387 21.7818 17.729 21.8885C16.8955 21.9983 15.7908 22 14.2069 22H9.7931C8.2092 22 7.10452 21.9983 6.27103 21.8885C5.46135 21.7818 5.03258 21.5868 4.72718 21.2876C4.42179 20.9884 4.22268 20.5683 4.11382 19.775C4.07259 19.4746 4.0463 19.1382 4.02952 18.7558C4.30088 18.0044 4.93365 17.4264 5.72738 17.218C6.01657 17.1421 6.39395 17.1351 7.47341 17.1351Z" />
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/DownloadIcon.tsx
Normal file
19
frontend/src/icons/DownloadIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface DownloadIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadIcon({ size = 24, className = '', disabled = false }: DownloadIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/icons/DropdownIcon.tsx
Normal file
15
frontend/src/icons/DropdownIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface DropdownIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownIcon({ size = 24, className = '', disabled = false }: DropdownIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled} viewBox="0 0 1792 1792">
|
||||||
|
<path d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z" />
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/icons/EditIcon.tsx
Normal file
17
frontend/src/icons/EditIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface EditIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditIcon({ size = 24, className = '', disabled = false }: EditIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z" />
|
||||||
|
<path d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z" />
|
||||||
|
<path d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z" />
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/HomeIcon.tsx
Normal file
19
frontend/src/icons/HomeIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface HomeIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeIcon({ size = 24, className = '', disabled = false }: HomeIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2.5192 7.82274C2 8.77128 2 9.91549 2 12.2039V13.725C2 17.6258 2 19.5763 3.17157 20.7881C4.34315 22 6.22876 22 10 22H14C17.7712 22 19.6569 22 20.8284 20.7881C22 19.5763 22 17.6258 22 13.725V12.2039C22 9.91549 22 8.77128 21.4808 7.82274C20.9616 6.87421 20.0131 6.28551 18.116 5.10812L16.116 3.86687C14.1106 2.62229 13.1079 2 12 2C10.8921 2 9.88939 2.62229 7.88403 3.86687L5.88403 5.10813C3.98695 6.28551 3.0384 6.87421 2.5192 7.82274ZM11.25 18C11.25 18.4142 11.5858 18.75 12 18.75C12.4142 18.75 12.75 18.4142 12.75 18V15C12.75 14.5858 12.4142 14.25 12 14.25C11.5858 14.25 11.25 14.5858 11.25 15V18Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/ImportIcon.tsx
Normal file
19
frontend/src/icons/ImportIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface ImportIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportIcon({ size = 24, className = '', disabled = false }: ImportIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2.06935 5.00839C2 5.37595 2 5.81722 2 6.69975V13.75C2 17.5212 2 19.4069 3.17157 20.5784C4.34315 21.75 6.22876 21.75 10 21.75H14C17.7712 21.75 19.6569 21.75 20.8284 20.5784C22 19.4069 22 17.5212 22 13.75V11.5479C22 8.91554 22 7.59935 21.2305 6.74383C21.1598 6.66514 21.0849 6.59024 21.0062 6.51946C20.1506 5.75 18.8345 5.75 16.2021 5.75H15.8284C14.6747 5.75 14.0979 5.75 13.5604 5.59678C13.2651 5.5126 12.9804 5.39471 12.7121 5.24543C12.2237 4.97367 11.8158 4.56578 11 3.75L10.4497 3.19975C10.1763 2.92633 10.0396 2.78961 9.89594 2.67051C9.27652 2.15704 8.51665 1.84229 7.71557 1.76738C7.52976 1.75 7.33642 1.75 6.94975 1.75C6.06722 1.75 5.62595 1.75 5.25839 1.81935C3.64031 2.12464 2.37464 3.39031 2.06935 5.00839ZM12 11C12.4142 11 12.75 11.3358 12.75 11.75V13H14C14.4142 13 14.75 13.3358 14.75 13.75C14.75 14.1642 14.4142 14.5 14 14.5H12.75V15.75C12.75 16.1642 12.4142 16.5 12 16.5C11.5858 16.5 11.25 16.1642 11.25 15.75V14.5H10C9.58579 14.5 9.25 14.1642 9.25 13.75C9.25 13.3358 9.58579 13 10 13H11.25V11.75C11.25 11.3358 11.5858 11 12 11Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/InfoIcon.tsx
Normal file
19
frontend/src/icons/InfoIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface InfoIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoIcon({ size = 24, className = '', disabled = false }: InfoIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75ZM12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/icons/LoadingIcon.tsx
Normal file
56
frontend/src/icons/LoadingIcon.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
interface LoadingIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.spinner_l9ve {
|
||||||
|
animation: spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite;
|
||||||
|
}
|
||||||
|
.spinner_cMYp {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
.spinner_gHR3 {
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
@keyframes spinner_rcyq {
|
||||||
|
0% {
|
||||||
|
transform: translate(12px, 12px) scale(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<path
|
||||||
|
className="spinner_l9ve"
|
||||||
|
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||||
|
transform="translate(12, 12) scale(0)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="spinner_l9ve spinner_cMYp"
|
||||||
|
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||||
|
transform="translate(12, 12) scale(0)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="spinner_l9ve spinner_gHR3"
|
||||||
|
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||||
|
transform="translate(12, 12) scale(0)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/icons/PasswordIcon.tsx
Normal file
15
frontend/src/icons/PasswordIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface PasswordIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordIcon({ size = 24, className = '', disabled = false }: PasswordIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled} viewBox="0 0 1792 1792">
|
||||||
|
<path d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z" />
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/icons/Search2Icon.tsx
Normal file
20
frontend/src/icons/Search2Icon.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface Search2IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Search2Icon({ size = 24, className = '', disabled = false }: Search2IconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<rect width="24" height="24" fill="none" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C11.8487 18 13.551 17.3729 14.9056 16.3199L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L16.3199 14.9056C17.3729 13.551 18 11.8487 18 10C18 5.58172 14.4183 2 10 2Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/SearchIcon.tsx
Normal file
19
frontend/src/icons/SearchIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface SearchIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchIcon({ size = 24, className = '', disabled = false }: SearchIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM9 11.5C9 10.1193 10.1193 9 11.5 9C12.8807 9 14 10.1193 14 11.5C14 12.8807 12.8807 14 11.5 14C10.1193 14 9 12.8807 9 11.5ZM11.5 7C9.01472 7 7 9.01472 7 11.5C7 13.9853 9.01472 16 11.5 16C12.3805 16 13.202 15.7471 13.8957 15.31L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L15.31 13.8957C15.7471 13.202 16 12.3805 16 11.5C16 9.01472 13.9853 7 11.5 7Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/SettingsIcon.tsx
Normal file
19
frontend/src/icons/SettingsIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface SettingsIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsIcon({ size = 24, className = '', disabled = false }: SettingsIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M14.2788 2.15224C13.9085 2 13.439 2 12.5 2C11.561 2 11.0915 2 10.7212 2.15224C10.2274 2.35523 9.83509 2.74458 9.63056 3.23463C9.53719 3.45834 9.50065 3.7185 9.48635 4.09799C9.46534 4.65568 9.17716 5.17189 8.69017 5.45093C8.20318 5.72996 7.60864 5.71954 7.11149 5.45876C6.77318 5.2813 6.52789 5.18262 6.28599 5.15102C5.75609 5.08178 5.22018 5.22429 4.79616 5.5472C4.47814 5.78938 4.24339 6.1929 3.7739 6.99993C3.30441 7.80697 3.06967 8.21048 3.01735 8.60491C2.94758 9.1308 3.09118 9.66266 3.41655 10.0835C3.56506 10.2756 3.77377 10.437 4.0977 10.639C4.57391 10.936 4.88032 11.4419 4.88029 12C4.88026 12.5581 4.57386 13.0639 4.0977 13.3608C3.77372 13.5629 3.56497 13.7244 3.41645 13.9165C3.09108 14.3373 2.94749 14.8691 3.01725 15.395C3.06957 15.7894 3.30432 16.193 3.7738 17C4.24329 17.807 4.47804 18.2106 4.79606 18.4527C5.22008 18.7756 5.75599 18.9181 6.28589 18.8489C6.52778 18.8173 6.77305 18.7186 7.11133 18.5412C7.60852 18.2804 8.2031 18.27 8.69012 18.549C9.17714 18.8281 9.46533 19.3443 9.48635 19.9021C9.50065 20.2815 9.53719 20.5417 9.63056 20.7654C9.83509 21.2554 10.2274 21.6448 10.7212 21.8478C11.0915 22 11.561 22 12.5 22C13.439 22 13.9085 22 14.2788 21.8478C14.7726 21.6448 15.1649 21.2554 15.3694 20.7654C15.4628 20.5417 15.4994 20.2815 15.5137 19.902C15.5347 19.3443 15.8228 18.8281 16.3098 18.549C16.7968 18.2699 17.3914 18.2804 17.8886 18.5412C18.2269 18.7186 18.4721 18.8172 18.714 18.8488C19.2439 18.9181 19.7798 18.7756 20.2038 18.4527C20.5219 18.2105 20.7566 17.807 21.2261 16.9999C21.6956 16.1929 21.9303 15.7894 21.9827 15.395C22.0524 14.8691 21.9088 14.3372 21.5835 13.9164C21.4349 13.7243 21.2262 13.5628 20.9022 13.3608C20.4261 13.0639 20.1197 12.558 20.1197 11.9999C20.1197 11.4418 20.4261 10.9361 20.9022 10.6392C21.2263 10.4371 21.435 10.2757 21.5836 10.0835C21.9089 9.66273 22.0525 9.13087 21.9828 8.60497C21.9304 8.21055 21.6957 7.80703 21.2262 7C20.7567 6.19297 20.522 5.78945 20.2039 5.54727C19.7799 5.22436 19.244 5.08185 18.7141 5.15109C18.4722 5.18269 18.2269 5.28136 17.8887 5.4588C17.3915 5.71959 16.7969 5.73002 16.3099 5.45096C15.8229 5.17191 15.5347 4.65566 15.5136 4.09794C15.4993 3.71848 15.4628 3.45833 15.3694 3.23463C15.1649 2.74458 14.7726 2.35523 14.2788 2.15224ZM12.5 15C14.1695 15 15.5228 13.6569 15.5228 12C15.5228 10.3431 14.1695 9 12.5 9C10.8305 9 9.47716 10.3431 9.47716 12C9.47716 13.6569 10.8305 15 12.5 15Z"
|
||||||
|
/>
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/icons/UploadIcon.tsx
Normal file
20
frontend/src/icons/UploadIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface UploadIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadIcon({ size = 24, className = '', disabled = false }: UploadIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
|
||||||
|
/>
|
||||||
|
<path d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z" />
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/icons/UserIcon.tsx
Normal file
15
frontend/src/icons/UserIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseIcon } from './BaseIcon';
|
||||||
|
|
||||||
|
interface UserIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserIcon({ size = 24, className = '', disabled = false }: UserIconProps) {
|
||||||
|
return (
|
||||||
|
<BaseIcon size={size} className={className} disabled={disabled} viewBox="0 0 1792 1792">
|
||||||
|
<path d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z" />
|
||||||
|
</BaseIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/icons/index.ts
Normal file
19
frontend/src/icons/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export { BaseIcon } from './BaseIcon';
|
||||||
|
export { HomeIcon } from './HomeIcon';
|
||||||
|
export { SettingsIcon } from './SettingsIcon';
|
||||||
|
export { SearchIcon } from './SearchIcon';
|
||||||
|
export { ActivityIcon } from './ActivityIcon';
|
||||||
|
export { AddIcon } from './AddIcon';
|
||||||
|
export { UserIcon } from './UserIcon';
|
||||||
|
export { DocumentsIcon } from './DocumentsIcon';
|
||||||
|
export { EditIcon } from './EditIcon';
|
||||||
|
export { DeleteIcon } from './DeleteIcon';
|
||||||
|
export { DownloadIcon } from './DownloadIcon';
|
||||||
|
export { UploadIcon } from './UploadIcon';
|
||||||
|
export { ImportIcon } from './ImportIcon';
|
||||||
|
export { InfoIcon } from './InfoIcon';
|
||||||
|
export { Search2Icon } from './Search2Icon';
|
||||||
|
export { DropdownIcon } from './DropdownIcon';
|
||||||
|
export { ClockIcon } from './ClockIcon';
|
||||||
|
export { PasswordIcon } from './PasswordIcon';
|
||||||
|
export { LoadingIcon } from './LoadingIcon';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { Search } from 'lucide-react';
|
import { SearchIcon } from '../icons';
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
export default function AdminLogsPage() {
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -27,7 +27,7 @@ export default function AdminLogsPage() {
|
|||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||||
<Search size={15} />
|
<SearchIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { AddIcon, DeleteIcon } from '../icons';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
@@ -162,7 +162,7 @@ export default function AdminUsersPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||||
<button onClick={() => setShowAddForm(!showAddForm)}>
|
<button onClick={() => setShowAddForm(!showAddForm)}>
|
||||||
<Plus size={20} />
|
<AddIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
||||||
@@ -192,7 +192,7 @@ export default function AdminUsersPage() {
|
|||||||
{/* Delete Button */}
|
{/* Delete Button */}
|
||||||
<td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
<td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
||||||
<button onClick={() => handleDeleteUser(user.id)}>
|
<button onClick={() => handleDeleteUser(user.id)}>
|
||||||
<Trash2 size={20} />
|
<DeleteIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
{/* User ID */}
|
{/* User ID */}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
import { useGetDocument, useGetProgress, useEditDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import { formatDuration, formatNumber } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
|
import { DeleteIcon, ActivityIcon, SearchIcon, DownloadIcon, EditIcon, InfoIcon } from '../icons';
|
||||||
|
import { X, Check } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,18 +37,34 @@ interface Progress {
|
|||||||
export default function DocumentPage() {
|
export default function DocumentPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||||
|
|
||||||
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
|
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
|
||||||
|
const editMutation = useEditDocument();
|
||||||
|
|
||||||
|
const [showEditCover, setShowEditCover] = useState(false);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
const [showIdentify, setShowIdentify] = useState(false);
|
||||||
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
|
const [isEditingAuthor, setIsEditingAuthor] = useState(false);
|
||||||
|
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||||
|
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||||
|
|
||||||
|
// Edit values - initialized after document is loaded
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editAuthor, setEditAuthor] = useState('');
|
||||||
|
const [editDescription, setEditDescription] = useState('');
|
||||||
|
|
||||||
if (docLoading || progressLoading) {
|
if (docLoading || progressLoading) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = docData?.data?.document as Document;
|
// Check for successful response (status 200)
|
||||||
const progressDataArray = progressData?.data?.progress;
|
if (!docData || docData.status !== 200) {
|
||||||
const progress = Array.isArray(progressDataArray)
|
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||||
? (progressDataArray[0] as Progress)
|
}
|
||||||
: undefined;
|
|
||||||
|
const document = docData.data.document as Document;
|
||||||
|
const progress =
|
||||||
|
progressData?.status === 200 ? (progressData.data.progress as Progress | undefined) : undefined;
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||||
@@ -56,21 +75,66 @@ export default function DocumentPage() {
|
|||||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||||
|
|
||||||
|
// Helper to start editing
|
||||||
|
const startEditing = (field: 'title' | 'author' | 'description') => {
|
||||||
|
if (field === 'title') setEditTitle(document.title);
|
||||||
|
if (field === 'author') setEditAuthor(document.author);
|
||||||
|
if (field === 'description') setEditDescription(document.description || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save edit handlers
|
||||||
|
const saveTitle = () => {
|
||||||
|
editMutation.mutate(
|
||||||
|
{
|
||||||
|
id: document.id,
|
||||||
|
data: { title: editTitle },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsEditingTitle(false),
|
||||||
|
onError: () => setIsEditingTitle(false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAuthor = () => {
|
||||||
|
editMutation.mutate(
|
||||||
|
{
|
||||||
|
id: document.id,
|
||||||
|
data: { author: editAuthor },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsEditingAuthor(false),
|
||||||
|
onError: () => setIsEditingAuthor(false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDescription = () => {
|
||||||
|
editMutation.mutate(
|
||||||
|
{
|
||||||
|
id: document.id,
|
||||||
|
data: { description: editDescription },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsEditingDescription(false),
|
||||||
|
onError: () => setIsEditingDescription(false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative size-full">
|
<div className="relative h-full w-full">
|
||||||
<div className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
|
<div className="h-full w-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||||
{/* Document Info - Left Column */}
|
{/* Document Info - Left Column */}
|
||||||
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
||||||
{/* Cover Image */}
|
{/* Cover Image with Edit Label */}
|
||||||
{document.filepath && (
|
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
|
||||||
<div className="h-60 w-full rounded bg-gray-200 object-fill dark:bg-gray-600">
|
<img
|
||||||
<img
|
className="rounded object-fill w-full"
|
||||||
className="h-full rounded object-cover"
|
src={`/api/v1/documents/${document.id}/cover`}
|
||||||
src={`/api/v1/documents/${document.id}/cover`}
|
alt={`${document.title} cover`}
|
||||||
alt={`${document.title} cover`}
|
/>
|
||||||
/>
|
</label>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Read Button - Only if file exists */}
|
{/* Read Button - Only if file exists */}
|
||||||
{document.filepath && (
|
{document.filepath && (
|
||||||
@@ -82,8 +146,9 @@ export default function DocumentPage() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons Container */}
|
||||||
<div className="relative z-20 my-2 flex flex-wrap-reverse justify-between gap-2">
|
<div className="relative z-20 flex flex-wrap-reverse justify-between gap-2">
|
||||||
|
{/* ISBN Info */}
|
||||||
<div className="min-w-[50%] md:mr-2">
|
<div className="min-w-[50%] md:mr-2">
|
||||||
<div className="flex gap-1 text-sm">
|
<div className="flex gap-1 text-sm">
|
||||||
<p className="text-gray-500">ISBN-10:</p>
|
<p className="text-gray-500">ISBN-10:</p>
|
||||||
@@ -95,112 +160,375 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download Button - Only if file exists */}
|
{/* Icons Container */}
|
||||||
{document.filepath && (
|
<div className="relative grow flex justify-between my-auto text-gray-500 dark:text-gray-500">
|
||||||
|
{/* Edit Cover Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="edit-cover-checkbox"
|
||||||
|
className="hidden"
|
||||||
|
checked={showEditCover}
|
||||||
|
onChange={e => setShowEditCover(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute z-30 flex flex-col gap-2 top-0 left-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${
|
||||||
|
showEditCover ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<form className="flex flex-col gap-2 w-72 text-black dark:text-white text-sm">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="cover_file"
|
||||||
|
name="cover_file"
|
||||||
|
className="p-2 bg-gray-300"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-700 py-1 px-2 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
||||||
|
>
|
||||||
|
Upload Cover
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form className="flex flex-col gap-2 w-72 text-black dark:text-white text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
id="remove_cover"
|
||||||
|
name="remove_cover"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-700 py-1 px-2 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
||||||
|
>
|
||||||
|
Remove Cover
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDelete(!showDelete)}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<DeleteIcon size={28} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${
|
||||||
|
showDelete ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<form className="text-black dark:text-white text-sm w-24">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-red-600 py-1 px-2 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Button */}
|
||||||
<a
|
<a
|
||||||
href={`/api/v1/documents/${document.id}/file`}
|
href={`/activity?document=${document.id}`}
|
||||||
className="z-10 text-gray-500 dark:text-gray-400"
|
aria-label="Activity"
|
||||||
title="Download"
|
className="hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
>
|
>
|
||||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<ActivityIcon size={28} />
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 16v1a3 3 0 003-3h4a3 3 0 003 3v1m0-3l-3 3m0 0L4 20"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
|
||||||
|
{/* Identify/Search Button */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowIdentify(!showIdentify)}
|
||||||
|
aria-label="Identify"
|
||||||
|
className="hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
<SearchIcon size={28} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${
|
||||||
|
showIdentify ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<form className="flex flex-col gap-2 text-black dark:text-white text-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
placeholder="Title"
|
||||||
|
defaultValue={document.title}
|
||||||
|
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
name="author"
|
||||||
|
placeholder="Author"
|
||||||
|
defaultValue={document.author}
|
||||||
|
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="isbn"
|
||||||
|
name="isbn"
|
||||||
|
placeholder="ISBN 10 / ISBN 13"
|
||||||
|
defaultValue={document.isbn13 || document.isbn10}
|
||||||
|
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-700 py-1 px-2 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
||||||
|
>
|
||||||
|
Identify
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Button */}
|
||||||
|
{document.filepath ? (
|
||||||
|
<a
|
||||||
|
href={`/api/v1/documents/${document.id}/file`}
|
||||||
|
aria-label="Download"
|
||||||
|
className="hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
<DownloadIcon size={28} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-200 dark:text-gray-600">
|
||||||
|
<DownloadIcon size={28} disabled />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Document Details Grid */}
|
{/* Document Details Grid */}
|
||||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||||
{/* Title - Editable */}
|
{/* Title - Editable */}
|
||||||
<div className="relative">
|
<div
|
||||||
|
className={`relative rounded p-2 ${isEditingTitle ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`}
|
||||||
|
>
|
||||||
<div className="relative inline-flex gap-2 text-gray-500">
|
<div className="relative inline-flex gap-2 text-gray-500">
|
||||||
<p>Title</p>
|
<p>Title</p>
|
||||||
|
{isEditingTitle ? (
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditingTitle(false)}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Cancel edit"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveTitle}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Confirm edit"
|
||||||
|
>
|
||||||
|
<Check size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
startEditing('title');
|
||||||
|
setIsEditingTitle(true);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Edit title"
|
||||||
|
>
|
||||||
|
<EditIcon size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative hyphens-auto text-justify font-medium">
|
{isEditingTitle ? (
|
||||||
<p>{document.title}</p>
|
<div className="relative flex gap-2 mt-1">
|
||||||
</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={e => setEditTitle(e.target.value)}
|
||||||
|
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded font-medium text-lg flex-grow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="font-medium text-lg">{document.title}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Author - Editable */}
|
{/* Author - Editable */}
|
||||||
<div className="relative">
|
<div
|
||||||
|
className={`relative rounded p-2 ${isEditingAuthor ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`}
|
||||||
|
>
|
||||||
<div className="relative inline-flex gap-2 text-gray-500">
|
<div className="relative inline-flex gap-2 text-gray-500">
|
||||||
<p>Author</p>
|
<p>Author</p>
|
||||||
|
{isEditingAuthor ? (
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditingAuthor(false)}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Cancel edit"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveAuthor}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Confirm edit"
|
||||||
|
>
|
||||||
|
<Check size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
startEditing('author');
|
||||||
|
setIsEditingAuthor(true);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Edit author"
|
||||||
|
>
|
||||||
|
<EditIcon size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative hyphens-auto text-justify font-medium">
|
{isEditingAuthor ? (
|
||||||
<p>{document.author}</p>
|
<div className="relative flex gap-2 mt-1">
|
||||||
</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editAuthor}
|
||||||
|
onChange={e => setEditAuthor(e.target.value)}
|
||||||
|
className="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded font-medium text-lg flex-grow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="font-medium text-lg">{document.author}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time Read */}
|
{/* Time Read with Info Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative inline-flex gap-2 text-gray-500">
|
<div className="relative inline-flex gap-2 text-gray-500">
|
||||||
<p>Time Read</p>
|
<p>Time Read</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
|
||||||
|
className="my-auto cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Show time read info"
|
||||||
|
>
|
||||||
|
<InfoIcon size={18} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg dark:bg-gray-600 ${
|
||||||
|
showTimeReadInfo ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs flex">
|
||||||
|
<p className="text-gray-400 w-32">Seconds / Percent</p>
|
||||||
|
<p className="font-medium dark:text-white">
|
||||||
|
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs flex">
|
||||||
|
<p className="text-gray-400 w-32">Words / Minute</p>
|
||||||
|
<p className="font-medium dark:text-white">
|
||||||
|
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs flex">
|
||||||
|
<p className="text-gray-400 w-32">Est. Time Left</p>
|
||||||
|
<p className="font-medium dark:text-white whitespace-nowrap">
|
||||||
|
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<p className="font-medium text-lg">
|
||||||
<p className="text-lg font-medium">
|
{document.total_time_seconds && document.total_time_seconds > 0
|
||||||
{document.total_time_seconds ? formatDuration(document.total_time_seconds) : 'N/A'}
|
? formatDuration(document.total_time_seconds)
|
||||||
</p>
|
: 'N/A'}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500">Progress</p>
|
<p className="text-gray-500">Progress</p>
|
||||||
<p className="text-lg font-medium">
|
<p className="font-medium text-lg">
|
||||||
{percentage ? `${Math.round(percentage)}%` : '0%'}
|
{percentage ? `${Math.round(percentage)}%` : '0%'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description - Editable */}
|
{/* Description - Editable */}
|
||||||
<div className="relative">
|
<div
|
||||||
|
className={`relative rounded p-2 ${isEditingDescription ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700' : ''}`}
|
||||||
|
>
|
||||||
<div className="relative inline-flex gap-2 text-gray-500">
|
<div className="relative inline-flex gap-2 text-gray-500">
|
||||||
<p>Description</p>
|
<p>Description</p>
|
||||||
|
{isEditingDescription ? (
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditingDescription(false)}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Cancel edit"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveDescription}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Confirm edit"
|
||||||
|
>
|
||||||
|
<Check size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
startEditing('description');
|
||||||
|
setIsEditingDescription(true);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
aria-label="Edit description"
|
||||||
|
>
|
||||||
|
<EditIcon size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative hyphens-auto text-justify font-medium">
|
{isEditingDescription ? (
|
||||||
<p>{document.description || 'N/A'}</p>
|
<div className="relative flex gap-2 mt-1">
|
||||||
</div>
|
<textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={e => setEditDescription(e.target.value)}
|
||||||
|
className="h-32 w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white rounded font-medium flex-grow"
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative font-medium text-justify hyphens-auto mt-1">
|
||||||
|
<p>{document.description || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reading Statistics */}
|
{/* Metadata Section */}
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
{/* TODO: Add metadata component when available */}
|
||||||
<div>
|
|
||||||
<p className="text-gray-500">Words</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{document.words != null ? formatNumber(document.words) : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500">Created</p>
|
|
||||||
<p className="font-medium">{new Date(document.created_at).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500">Updated</p>
|
|
||||||
<p className="font-medium">{new Date(document.updated_at).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Reading Stats - Matching Legacy Template */}
|
|
||||||
{progress && (
|
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-gray-500">Words / Minute:</p>
|
|
||||||
<p className="font-medium">{document.wpm || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-gray-500">Est. Time Left:</p>
|
|
||||||
<p className="whitespace-nowrap font-medium">
|
|
||||||
{formatDuration(totalTimeLeftSeconds)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, FormEvent, useRef } from 'react';
|
import { useState, FormEvent, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import { Activity, Download, Search, Upload } from 'lucide-react';
|
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
@@ -64,14 +64,14 @@ function DocumentCard({ doc }: DocumentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
|
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
|
||||||
<Link to={`/activity?document=${doc.id}`}>
|
<Link to={`/activity?document=${doc.id}`}>
|
||||||
<Activity size={20} />
|
<ActivityIcon size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
{doc.filepath ? (
|
{doc.filepath ? (
|
||||||
<a href={`/api/v1/documents/${doc.id}/file`}>
|
<a href={`/api/v1/documents/${doc.id}/file`}>
|
||||||
<Download size={20} />
|
<DownloadIcon size={20} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Download size={20} className="text-gray-400" />
|
<DownloadIcon size={20} disabled />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ export default function DocumentsPage() {
|
|||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||||
<Search size={15} />
|
<SearchIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -232,7 +232,7 @@ export default function DocumentsPage() {
|
|||||||
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-gray-800 opacity-30 transition-all duration-200 hover:opacity-100 dark:bg-gray-200"
|
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-gray-800 opacity-30 transition-all duration-200 hover:opacity-100 dark:bg-gray-200"
|
||||||
htmlFor="upload-file-button"
|
htmlFor="upload-file-button"
|
||||||
>
|
>
|
||||||
<Upload size={34} />
|
<UploadIcon size={34} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
|
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||||
import type { LeaderboardData } from '../generated/model';
|
import type { LeaderboardData } from '../generated/model';
|
||||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||||
@@ -191,15 +191,13 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||||
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });
|
|
||||||
|
|
||||||
const docs = docsData?.data?.documents;
|
|
||||||
const dbInfo = homeData?.data?.database_info;
|
const dbInfo = homeData?.data?.database_info;
|
||||||
const streaks = homeData?.data?.streaks?.streaks;
|
const streaks = homeData?.data?.streaks?.streaks;
|
||||||
const graphData = homeData?.data?.graph_data?.graph_data;
|
const graphData = homeData?.data?.graph_data?.graph_data;
|
||||||
const userStats = homeData?.data?.user_statistics;
|
const userStats = homeData?.data?.user_statistics;
|
||||||
|
|
||||||
if (homeLoading || docsLoading) {
|
if (homeLoading) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,25 +252,6 @@ export default function HomePage() {
|
|||||||
data={userStats?.words || { all: [], year: [], month: [], week: [] }}
|
data={userStats?.words || { all: [], year: [], month: [], week: [] }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Documents */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{docs?.slice(0, 6).map((doc: any) => (
|
|
||||||
<div
|
|
||||||
key={doc.id}
|
|
||||||
className="flex flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-medium">{doc.title}</h3>
|
|
||||||
<p className="text-sm">{doc.author}</p>
|
|
||||||
<Link
|
|
||||||
to={`/documents/${doc.id}`}
|
|
||||||
className="rounded bg-blue-700 py-1 text-center text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600 dark:hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
View Document
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||||
import { Search, Book, Download } from 'lucide-react';
|
import { SearchIcon, DownloadIcon } from '../icons';
|
||||||
|
import { Book } from 'lucide-react';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
@@ -25,7 +26,7 @@ export default function SearchPage() {
|
|||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||||
<Search size={15} />
|
<SearchIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -101,7 +102,7 @@ export default function SearchPage() {
|
|||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
|
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
|
||||||
<button className="hover:text-purple-600" title="Download">
|
<button className="hover:text-purple-600" title="Download">
|
||||||
<Download size={15} />
|
<DownloadIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-gray-200 p-3">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, FormEvent } from 'react';
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
||||||
import { User, Lock, Clock } from 'lucide-react';
|
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export default function SettingsPage() {
|
|||||||
{/* User Profile Card */}
|
{/* User Profile Card */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col items-center rounded bg-white p-4 text-gray-500 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700 dark:text-white">
|
<div className="flex flex-col items-center rounded bg-white p-4 text-gray-500 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700 dark:text-white">
|
||||||
<User size={60} />
|
<UserIcon size={60} />
|
||||||
<p className="text-lg">{settingsData?.data.user.username || 'N/A'}</p>
|
<p className="text-lg">{settingsData?.data.user.username || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +121,7 @@ export default function SettingsPage() {
|
|||||||
<div className="flex grow flex-col">
|
<div className="flex grow flex-col">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||||
<Lock size={15} />
|
<PasswordIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -135,7 +135,7 @@ export default function SettingsPage() {
|
|||||||
<div className="flex grow flex-col">
|
<div className="flex grow flex-col">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||||
<Lock size={15} />
|
<PasswordIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -160,7 +160,7 @@ export default function SettingsPage() {
|
|||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
|
||||||
<div className="relative flex grow">
|
<div className="relative flex grow">
|
||||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||||
<Clock size={15} />
|
<ClockIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={timezone || 'UTC'}
|
value={timezone || 'UTC'}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -40,10 +41,14 @@ const GBOOKS_GBID_INFO_URL string = "https://www.googleapis.com/books/v1/volumes
|
|||||||
const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690"
|
const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690"
|
||||||
|
|
||||||
func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||||
|
return getGBooksMetadataWithContext(context.Background(), metadataSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGBooksMetadataWithContext(ctx context.Context, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||||
var queryResults []gBooksQueryItem
|
var queryResults []gBooksQueryItem
|
||||||
if metadataSearch.ID != nil {
|
if metadataSearch.ID != nil {
|
||||||
// Use GBID
|
// Use GBID
|
||||||
resp, err := performGBIDRequest(*metadataSearch.ID)
|
resp, err := performGBIDRequestWithContext(ctx, *metadataSearch.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -51,7 +56,7 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
|||||||
queryResults = []gBooksQueryItem{*resp}
|
queryResults = []gBooksQueryItem{*resp}
|
||||||
} else if metadataSearch.ISBN13 != nil {
|
} else if metadataSearch.ISBN13 != nil {
|
||||||
searchQuery := "isbn:" + *metadataSearch.ISBN13
|
searchQuery := "isbn:" + *metadataSearch.ISBN13
|
||||||
resp, err := performSearchRequest(searchQuery)
|
resp, err := performSearchRequestWithContext(ctx, searchQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -59,7 +64,7 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
|||||||
queryResults = resp.Items
|
queryResults = resp.Items
|
||||||
} else if metadataSearch.ISBN10 != nil {
|
} else if metadataSearch.ISBN10 != nil {
|
||||||
searchQuery := "isbn:" + *metadataSearch.ISBN10
|
searchQuery := "isbn:" + *metadataSearch.ISBN10
|
||||||
resp, err := performSearchRequest(searchQuery)
|
resp, err := performSearchRequestWithContext(ctx, searchQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,7 +81,7 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
|||||||
|
|
||||||
// Escape & Trim
|
// Escape & Trim
|
||||||
searchQuery = url.QueryEscape(strings.TrimSpace(searchQuery))
|
searchQuery = url.QueryEscape(strings.TrimSpace(searchQuery))
|
||||||
resp, err := performSearchRequest(searchQuery)
|
resp, err := performSearchRequestWithContext(ctx, searchQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -119,6 +124,10 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
||||||
|
return saveGBooksCoverWithContext(context.Background(), gbid, coverFilePath, overwrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveGBooksCoverWithContext(ctx context.Context, gbid string, coverFilePath string, overwrite bool) error {
|
||||||
// Validate File Doesn't Exists
|
// Validate File Doesn't Exists
|
||||||
_, err := os.Stat(coverFilePath)
|
_, err := os.Stat(coverFilePath)
|
||||||
if err == nil && !overwrite {
|
if err == nil && !overwrite {
|
||||||
@@ -137,7 +146,14 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
|||||||
// Download File
|
// Download File
|
||||||
log.Info("Downloading Cover")
|
log.Info("Downloading Cover")
|
||||||
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, gbid)
|
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, gbid)
|
||||||
resp, err := http.Get(coverURL)
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", coverURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Cover URL API Failure")
|
||||||
|
return errors.New("API Failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Cover URL API Failure")
|
log.Error("Cover URL API Failure")
|
||||||
return errors.New("API Failure")
|
return errors.New("API Failure")
|
||||||
@@ -156,9 +172,20 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
||||||
|
return performSearchRequestWithContext(context.Background(), searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSearchRequestWithContext(ctx context.Context, searchQuery string) (*gBooksQueryResponse, error) {
|
||||||
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
|
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
|
||||||
log.Info("Acquiring Metadata: ", apiQuery)
|
log.Info("Acquiring Metadata: ", apiQuery)
|
||||||
resp, err := http.Get(apiQuery)
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiQuery, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Google Books Query URL API Failure")
|
||||||
|
return nil, errors.New("API Failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Google Books Query URL API Failure")
|
log.Error("Google Books Query URL API Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
@@ -166,6 +193,7 @@ func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
|||||||
|
|
||||||
parsedResp := gBooksQueryResponse{}
|
parsedResp := gBooksQueryResponse{}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
||||||
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Google Books Query API Decode Failure")
|
log.Error("Google Books Query API Decode Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
@@ -180,10 +208,21 @@ func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func performGBIDRequest(id string) (*gBooksQueryItem, error) {
|
func performGBIDRequest(id string) (*gBooksQueryItem, error) {
|
||||||
|
return performGBIDRequestWithContext(context.Background(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performGBIDRequestWithContext(ctx context.Context, id string) (*gBooksQueryItem, error) {
|
||||||
apiQuery := fmt.Sprintf(GBOOKS_GBID_INFO_URL, id)
|
apiQuery := fmt.Sprintf(GBOOKS_GBID_INFO_URL, id)
|
||||||
|
|
||||||
log.Info("Acquiring CoverID")
|
log.Info("Acquiring CoverID")
|
||||||
resp, err := http.Get(apiQuery)
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiQuery, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Cover URL API Failure")
|
||||||
|
return nil, errors.New("API Failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Cover URL API Failure")
|
log.Error("Cover URL API Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
@@ -191,6 +230,7 @@ func performGBIDRequest(id string) (*gBooksQueryItem, error) {
|
|||||||
|
|
||||||
parsedResp := gBooksQueryItem{}
|
parsedResp := gBooksQueryItem{}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
||||||
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Google Books ID API Decode Failure")
|
log.Error("Google Books ID API Decode Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -45,12 +46,17 @@ type MetadataInfo struct {
|
|||||||
|
|
||||||
// Downloads the Google Books cover file and saves it to the provided directory.
|
// Downloads the Google Books cover file and saves it to the provided directory.
|
||||||
func CacheCover(gbid string, coverDir string, documentID string, overwrite bool) (*string, error) {
|
func CacheCover(gbid string, coverDir string, documentID string, overwrite bool) (*string, error) {
|
||||||
|
return CacheCoverWithContext(context.Background(), gbid, coverDir, documentID, overwrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheCoverWithContext downloads the Google Books cover file and saves it to the provided directory with context support.
|
||||||
|
func CacheCoverWithContext(ctx context.Context, gbid string, coverDir string, documentID string, overwrite bool) (*string, error) {
|
||||||
// Get Filepath
|
// Get Filepath
|
||||||
coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID))
|
coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID))
|
||||||
coverFilePath := filepath.Join(coverDir, coverFile)
|
coverFilePath := filepath.Join(coverDir, coverFile)
|
||||||
|
|
||||||
// Save Google Books
|
// Save Google Books
|
||||||
if err := saveGBooksCover(gbid, coverFilePath, overwrite); err != nil {
|
if err := saveGBooksCoverWithContext(ctx, gbid, coverFilePath, overwrite); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +67,14 @@ func CacheCover(gbid string, coverDir string, documentID string, overwrite bool)
|
|||||||
|
|
||||||
// Searches source for metadata based on the provided information.
|
// Searches source for metadata based on the provided information.
|
||||||
func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||||
|
return SearchMetadataWithContext(context.Background(), s, metadataSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchMetadataWithContext searches source for metadata based on the provided information with context support.
|
||||||
|
func SearchMetadataWithContext(ctx context.Context, s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||||
switch s {
|
switch s {
|
||||||
case SOURCE_GBOOK:
|
case SOURCE_GBOOK:
|
||||||
return getGBooksMetadata(metadataSearch)
|
return getGBooksMetadataWithContext(ctx, metadataSearch)
|
||||||
case SOURCE_OLIB:
|
case SOURCE_OLIB:
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user