This commit is contained in:
2026-03-21 20:47:22 -04:00
parent ba919bbde4
commit 4d133994ab
55 changed files with 1901 additions and 264 deletions

View File

@@ -1,41 +1,31 @@
# AnthoLume - Agent Context
## Migration Context
Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec)
## Critical Rules
### Generated Files
- **NEVER edit generated files** - Always edit the source and regenerate
- Go backend API: Edit `api/v1/openapi.yaml` then run `go generate ./api/v1/generate.go`
- TS client: Regenerate with `cd frontend && npm run generate:api`
- Examples of generated files:
- **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`
- `cd frontend && bun run generate:api`
- Examples of generated files:
- `api/v1/api.gen.go`
- `frontend/src/generated/**/*.ts`
### Database Access
- **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
1. Check legacy implementation for business logic
2. Copy pattern but adapt to use `s.db.Queries.*` instead of `api.db.Queries.*`
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`.
### Error Handling
- Use `fmt.Errorf("message: %w", err)` for wrapping errors
- Do NOT use `github.com/pkg/errors`
## Frontend
- **Package manager**: bun (not npm)
- **Icons**: Use `lucide-react` for all icons (not custom SVGs)
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
- **Format**: `cd frontend && bun run format` (and `format:fix`)
- **Generate API client**: `cd frontend && bun run generate:api`
## Regeneration
- Go backend: `go generate ./api/v1/generate.go`
- TS client: `cd frontend && npm run generate:api`
- TS client: `cd frontend && bun run generate:api`

BIN
antholume Executable file

Binary file not shown.

View File

@@ -69,7 +69,6 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
response := ActivityResponse{
Activities: apiActivities,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return GetActivity200JSONResponse(response), nil
}

View File

@@ -159,7 +159,6 @@ type Activity struct {
// ActivityResponse defines model for ActivityResponse.
type ActivityResponse struct {
Activities []Activity `json:"activities"`
User UserData `json:"user"`
}
// BackupType defines model for BackupType.
@@ -217,7 +216,6 @@ type Document struct {
type DocumentResponse struct {
Document Document `json:"document"`
Progress *Progress `json:"progress,omitempty"`
User UserData `json:"user"`
}
// DocumentsResponse defines model for DocumentsResponse.
@@ -248,7 +246,6 @@ type GraphDataPoint struct {
// GraphDataResponse defines model for GraphDataResponse.
type GraphDataResponse struct {
GraphData []GraphDataPoint `json:"graph_data"`
User UserData `json:"user"`
}
// HomeResponse defines model for HomeResponse.
@@ -256,7 +253,6 @@ type HomeResponse struct {
DatabaseInfo DatabaseInfo `json:"database_info"`
GraphData GraphDataResponse `json:"graph_data"`
Streaks StreaksResponse `json:"streaks"`
User UserData `json:"user"`
UserStatistics UserStatisticsResponse `json:"user_statistics"`
}
@@ -349,13 +345,11 @@ type ProgressListResponse struct {
PreviousPage *int64 `json:"previous_page,omitempty"`
Progress *[]Progress `json:"progress,omitempty"`
Total *int64 `json:"total,omitempty"`
User *UserData `json:"user,omitempty"`
}
// ProgressResponse defines model for ProgressResponse.
type ProgressResponse struct {
Progress *Progress `json:"progress,omitempty"`
User *UserData `json:"user,omitempty"`
}
// SearchItem defines model for SearchItem.
@@ -387,7 +381,6 @@ type SettingsResponse struct {
// StreaksResponse defines model for StreaksResponse.
type StreaksResponse struct {
Streaks []UserStreak `json:"streaks"`
User UserData `json:"user"`
}
// UpdateSettingsRequest defines model for UpdateSettingsRequest.
@@ -413,7 +406,6 @@ type UserData struct {
// UserStatisticsResponse defines model for UserStatisticsResponse.
type UserStatisticsResponse struct {
Duration LeaderboardData `json:"duration"`
User UserData `json:"user"`
Words LeaderboardData `json:"words"`
Wpm LeaderboardData `json:"wpm"`
}
@@ -495,6 +487,21 @@ type CreateDocumentMultipartBody struct {
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.
type GetProgressListParams struct {
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.
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.
type PostSearchFormdataRequestBody PostSearchFormdataBody
@@ -587,9 +600,15 @@ type ServerInterface interface {
// Get a single document
// (GET /documents/{id})
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 /documents/{id}/cover)
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
// (GET /documents/{id}/file)
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)
}
// 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
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)
}
// 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
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("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
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("POST "+options.BaseURL+"/documents/{id}/cover", wrapper.UploadDocumentCover)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/file", wrapper.GetDocumentFile)
m.HandleFunc("GET "+options.BaseURL+"/home", wrapper.GetHome)
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)
}
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 {
Id string `json:"id"`
}
@@ -2185,6 +2322,60 @@ func (response GetDocumentCover500JSONResponse) VisitGetDocumentCoverResponse(w
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 {
Id string `json:"id"`
}
@@ -2682,9 +2873,15 @@ type StrictServerInterface interface {
// Get a single document
// (GET /documents/{id})
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 /documents/{id}/cover)
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
// (GET /documents/{id}/file)
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
func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
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
func (sh *strictHandler) GetDocumentFile(w http.ResponseWriter, r *http.Request, id string) {
var request GetDocumentFileRequestObject

View File

@@ -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
// 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)
session.Options.SameSite = http.SameSiteLaxMode
@@ -143,6 +149,15 @@ func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
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
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
r, ok := ctx.Value("request").(*http.Request)

View File

@@ -154,12 +154,111 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
response := DocumentResponse{
Document: apiDoc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Progress: progress,
}
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.
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// 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")
if needMetadataFetch {
// Create context with timeout for metadata service calls
metadataCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// 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,
Author: document.Author,
})
@@ -306,7 +409,7 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
firstResult := metadataResults[0]
// 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 {
cachedCoverFile = *fileName
}
@@ -368,6 +471,136 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
}, 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
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
// 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
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
_, ok := s.getSessionFromContext(ctx)
if !ok {
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
}
// 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
tempFile, err := os.CreateTemp("", "book")
if err != nil {
@@ -502,7 +744,6 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
}
response := DocumentResponse{
Document: apiDoc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return CreateDocument200JSONResponse(response), nil
}
@@ -551,7 +792,6 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
response := DocumentResponse{
Document: apiDoc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return CreateDocument200JSONResponse(response), nil

View File

@@ -54,23 +54,11 @@ func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (Get
},
Streaks: StreaksResponse{
Streaks: convertStreaks(streaks),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
},
GraphData: GraphDataResponse{
GraphData: convertGraphData(graphData),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
},
UserStatistics: arrangeUserStatistics(userStats),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
}
return GetHome200JSONResponse(response), nil
@@ -91,10 +79,6 @@ func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject
response := StreaksResponse{
Streaks: convertStreaks(streaks),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
}
return GetStreaks200JSONResponse(response), nil
@@ -115,10 +99,6 @@ func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestOb
response := GraphDataResponse{
GraphData: convertGraphData(graphData),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
}
return GetGraphData200JSONResponse(response), nil
@@ -126,7 +106,7 @@ func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestOb
// GET /home/statistics
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
_, ok := s.getSessionFromContext(ctx)
if !ok {
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
@@ -138,11 +118,6 @@ func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatistic
}
response := arrangeUserStatistics(userStats)
response.User = UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
}
return GetUserStatistics200JSONResponse(response), nil
}

View File

@@ -233,13 +233,10 @@ components:
properties:
document:
$ref: '#/components/schemas/Document'
user:
$ref: '#/components/schemas/UserData'
progress:
$ref: '#/components/schemas/Progress'
required:
- document
- user
ProgressListResponse:
type: object
@@ -248,8 +245,6 @@ components:
type: array
items:
$ref: '#/components/schemas/Progress'
user:
$ref: '#/components/schemas/UserData'
page:
type: integer
format: int64
@@ -271,8 +266,6 @@ components:
properties:
progress:
$ref: '#/components/schemas/Progress'
user:
$ref: '#/components/schemas/UserData'
ActivityResponse:
type: object
@@ -281,11 +274,8 @@ components:
type: array
items:
$ref: '#/components/schemas/Activity'
user:
$ref: '#/components/schemas/UserData'
required:
- activities
- user
Device:
type: object
@@ -423,11 +413,8 @@ components:
type: array
items:
$ref: '#/components/schemas/UserStreak'
user:
$ref: '#/components/schemas/UserData'
required:
- streaks
- user
GraphDataPoint:
type: object
@@ -448,11 +435,8 @@ components:
type: array
items:
$ref: '#/components/schemas/GraphDataPoint'
user:
$ref: '#/components/schemas/UserData'
required:
- graph_data
- user
LeaderboardEntry:
type: object
@@ -500,13 +484,10 @@ components:
$ref: '#/components/schemas/LeaderboardData'
words:
$ref: '#/components/schemas/LeaderboardData'
user:
$ref: '#/components/schemas/UserData'
required:
- wpm
- duration
- words
- user
HomeResponse:
type: object
@@ -519,14 +500,11 @@ components:
$ref: '#/components/schemas/GraphDataResponse'
user_statistics:
$ref: '#/components/schemas/UserStatisticsResponse'
user:
$ref: '#/components/schemas/UserData'
required:
- database_info
- streaks
- graph_data
- user_statistics
- user
BackupType:
type: string
@@ -765,6 +743,69 @@ paths:
application/json:
schema:
$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:
get:
@@ -804,6 +845,62 @@ paths:
application/json:
schema:
$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:
get:

View File

@@ -70,7 +70,6 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq
response := ProgressListResponse{
Progress: &apiProgress,
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Page: &page,
Limit: &limit,
NextPage: nextPage,
@@ -119,7 +118,6 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
response := ProgressResponse{
Progress: &apiProgress,
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return GetProgress200JSONResponse(response), nil

View File

@@ -60,6 +60,28 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
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
ctx = context.WithValue(ctx, "auth", auth)

View File

@@ -40,15 +40,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} else if (meData?.data && meData.status === 200) {
// User is authenticated - check that response has valid data
console.log('[AuthContext] User authenticated:', meData.data);
const userData = 'username' in meData.data ? meData.data : null;
return {
isAuthenticated: true,
user: meData.data,
user: userData as { username: string; is_admin: boolean } | null,
isCheckingAuth: false,
};
} else if (
meError ||
(meData && meData.status === 401) ||
(meData && meData.status === 403)
(meData && meData.status === 401)
) {
// User is not authenticated or error occurred
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
setAuthState({
isAuthenticated: true,
user: response.data,
user: 'username' in response.data ? response.data as { username: string; is_admin: boolean } : null,
isCheckingAuth: false,
});

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
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 { useGetInfo } from '../generated/anthoLumeAPIV1';
@@ -12,18 +12,18 @@ interface NavItem {
}
const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: Home, title: 'Home' },
{ path: '/documents', label: 'Documents', icon: FileText, title: 'Documents' },
{ path: '/progress', label: 'Progress', icon: Activity, title: 'Progress' },
{ path: '/activity', label: 'Activity', icon: Activity, title: 'Activity' },
{ path: '/search', label: 'Search', icon: Search, title: 'Search' },
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
];
const adminSubItems: NavItem[] = [
{ path: '/admin', label: 'General', icon: Settings, title: 'General' },
{ path: '/admin/import', label: 'Import', icon: Settings, title: 'Import' },
{ path: '/admin/users', label: 'Users', icon: Settings, title: 'Users' },
{ path: '/admin/logs', label: 'Logs', icon: Settings, title: 'Logs' },
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
];
// 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'
}`}
>
<Settings size={20} />
<SettingsIcon size={20} />
<span className="mx-4 text-sm font-normal">Admin</span>
</Link>

View File

@@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react';
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useGetMe } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext';
import { User, ChevronDown } from 'lucide-react';
import { UserIcon } from '../icons';
import { ChevronDown } from 'lucide-react';
import HamburgerMenu from './HamburgerMenu';
export default function Layout() {
@@ -73,7 +74,7 @@ export default function Layout() {
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="relative block text-gray-800 dark:text-gray-200"
>
<User size={20} />
<UserIcon size={20} />
</button>
{isUserDropdownOpen && (
@@ -113,7 +114,7 @@ export default function Layout() {
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
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
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}

View File

@@ -30,6 +30,7 @@ import type {
DirectoryListResponse,
DocumentResponse,
DocumentsResponse,
EditDocumentBody,
ErrorResponse,
GetActivityParams,
GetAdmin200,
@@ -56,6 +57,7 @@ import type {
StreaksResponse,
UpdateSettingsRequest,
UpdateUserBody,
UploadDocumentCoverBody,
UserStatisticsResponse,
UsersResponse
} 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
*/
@@ -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
*/

View File

@@ -6,9 +6,7 @@
* OpenAPI spec version: 1.0.0
*/
import type { Activity } from './activity';
import type { UserData } from './userData';
export interface ActivityResponse {
activities: Activity[];
user: UserData;
}

View File

@@ -7,10 +7,8 @@
*/
import type { Document } from './document';
import type { Progress } from './progress';
import type { UserData } from './userData';
export interface DocumentResponse {
document: Document;
user: UserData;
progress?: Progress;
}

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

View File

@@ -6,9 +6,7 @@
* OpenAPI spec version: 1.0.0
*/
import type { GraphDataPoint } from './graphDataPoint';
import type { UserData } from './userData';
export interface GraphDataResponse {
graph_data: GraphDataPoint[];
user: UserData;
}

View File

@@ -8,7 +8,6 @@
import type { DatabaseInfo } from './databaseInfo';
import type { GraphDataResponse } from './graphDataResponse';
import type { StreaksResponse } from './streaksResponse';
import type { UserData } from './userData';
import type { UserStatisticsResponse } from './userStatisticsResponse';
export interface HomeResponse {
@@ -16,5 +15,4 @@ export interface HomeResponse {
streaks: StreaksResponse;
graph_data: GraphDataResponse;
user_statistics: UserStatisticsResponse;
user: UserData;
}

View File

@@ -18,6 +18,7 @@ export * from './directoryListResponse';
export * from './document';
export * from './documentResponse';
export * from './documentsResponse';
export * from './editDocumentBody';
export * from './errorResponse';
export * from './getActivityParams';
export * from './getAdmin200';
@@ -55,8 +56,10 @@ export * from './searchResponse';
export * from './setting';
export * from './settingsResponse';
export * from './streaksResponse';
export * from './updateDocumentBody';
export * from './updateSettingsRequest';
export * from './updateUserBody';
export * from './uploadDocumentCoverBody';
export * from './user';
export * from './userData';
export * from './usersResponse';

View File

@@ -6,11 +6,9 @@
* OpenAPI spec version: 1.0.0
*/
import type { Progress } from './progress';
import type { UserData } from './userData';
export interface ProgressListResponse {
progress?: Progress[];
user?: UserData;
page?: number;
limit?: number;
next_page?: number;

View File

@@ -6,9 +6,7 @@
* OpenAPI spec version: 1.0.0
*/
import type { Progress } from './progress';
import type { UserData } from './userData';
export interface ProgressResponse {
progress?: Progress;
user?: UserData;
}

View File

@@ -5,10 +5,8 @@
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { UserData } from './userData';
import type { UserStreak } from './userStreak';
export interface StreaksResponse {
streaks: UserStreak[];
user: UserData;
}

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

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

View File

@@ -6,11 +6,9 @@
* OpenAPI spec version: 1.0.0
*/
import type { LeaderboardData } from './leaderboardData';
import type { UserData } from './userData';
export interface UserStatisticsResponse {
wpm: LeaderboardData;
duration: LeaderboardData;
words: LeaderboardData;
user: UserData;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,7 +1,7 @@
import { useState, FormEvent } from 'react';
import { useGetLogs } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
import { Search } from 'lucide-react';
import { SearchIcon } from '../icons';
export default function AdminLogsPage() {
const [filter, setFilter] = useState('');
@@ -27,7 +27,7 @@ export default function AdminLogsPage() {
<div className="flex w-full grow flex-col">
<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">
<Search size={15} />
<SearchIcon size={15} />
</span>
<input
type="text"

View File

@@ -1,6 +1,6 @@
import { useState, FormEvent } from 'react';
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
import { Plus, Trash2 } from 'lucide-react';
import { AddIcon, DeleteIcon } from '../icons';
import { useToasts } from '../components/ToastContext';
export default function AdminUsersPage() {
@@ -162,7 +162,7 @@ export default function AdminUsersPage() {
<tr>
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
<button onClick={() => setShowAddForm(!showAddForm)}>
<Plus size={20} />
<AddIcon size={20} />
</button>
</th>
<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 */}
<td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
<button onClick={() => handleDeleteUser(user.id)}>
<Trash2 size={20} />
<DeleteIcon size={20} />
</button>
</td>
{/* User ID */}

View File

@@ -1,6 +1,9 @@
import { useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { formatDuration, formatNumber } from '../utils/formatters';
import { useGetDocument, useGetProgress, useEditDocument } from '../generated/anthoLumeAPIV1';
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 {
id: string;
@@ -34,18 +37,34 @@ interface Progress {
export default function DocumentPage() {
const { id } = useParams<{ id: string }>();
const { data: docData, isLoading: docLoading } = useGetDocument(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) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
const document = docData?.data?.document as Document;
const progressDataArray = progressData?.data?.progress;
const progress = Array.isArray(progressDataArray)
? (progressDataArray[0] as Progress)
: undefined;
// Check for successful response (status 200)
if (!docData || docData.status !== 200) {
return <div className="text-gray-500 dark:text-white">Document not found</div>;
}
const document = docData.data.document as Document;
const progress =
progressData?.status === 200 ? (progressData.data.progress as Progress | undefined) : undefined;
if (!document) {
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 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 (
<div className="relative size-full">
<div className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
<div className="relative h-full w-full">
<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 */}
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
{/* Cover Image */}
{document.filepath && (
<div className="h-60 w-full rounded bg-gray-200 object-fill dark:bg-gray-600">
{/* Cover Image with Edit Label */}
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
<img
className="h-full rounded object-cover"
className="rounded object-fill w-full"
src={`/api/v1/documents/${document.id}/cover`}
alt={`${document.title} cover`}
/>
</div>
)}
</label>
{/* Read Button - Only if file exists */}
{document.filepath && (
@@ -82,8 +146,9 @@ export default function DocumentPage() {
</a>
)}
{/* Action Buttons */}
<div className="relative z-20 my-2 flex flex-wrap-reverse justify-between gap-2">
{/* Action Buttons Container */}
<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="flex gap-1 text-sm">
<p className="text-gray-500">ISBN-10:</p>
@@ -95,113 +160,376 @@ export default function DocumentPage() {
</div>
</div>
{/* Download Button - Only if file exists */}
{document.filepath && (
{/* Icons Container */}
<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
href={`/activity?document=${document.id}`}
aria-label="Activity"
className="hover:text-gray-800 dark:hover:text-gray-100"
>
<ActivityIcon size={28} />
</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`}
className="z-10 text-gray-500 dark:text-gray-400"
title="Download"
aria-label="Download"
className="hover:text-gray-800 dark:hover:text-gray-100"
>
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003-3h4a3 3 0 003 3v1m0-3l-3 3m0 0L4 20"
/>
</svg>
<DownloadIcon size={28} />
</a>
) : (
<span className="text-gray-200 dark:text-gray-600">
<DownloadIcon size={28} disabled />
</span>
)}
</div>
</div>
</div>
{/* Document Details Grid */}
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
{/* 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">
<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>
<div className="relative hyphens-auto text-justify font-medium">
<p>{document.title}</p>
) : (
<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>
{isEditingTitle ? (
<div className="relative flex gap-2 mt-1">
<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>
{/* 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">
<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>
<div className="relative hyphens-auto text-justify font-medium">
<p>{document.author}</p>
) : (
<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>
{isEditingAuthor ? (
<div className="relative flex gap-2 mt-1">
<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>
{/* Time Read */}
{/* Time Read with Info Dropdown */}
<div className="relative">
<div className="relative inline-flex gap-2 text-gray-500">
<p>Time Read</p>
</div>
<div className="relative">
<p className="text-lg font-medium">
{document.total_time_seconds ? formatDuration(document.total_time_seconds) : 'N/A'}
<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>
<p className="font-medium text-lg">
{document.total_time_seconds && document.total_time_seconds > 0
? formatDuration(document.total_time_seconds)
: 'N/A'}
</p>
</div>
{/* Progress */}
<div>
<p className="text-gray-500">Progress</p>
<p className="text-lg font-medium">
<p className="font-medium text-lg">
{percentage ? `${Math.round(percentage)}%` : '0%'}
</p>
</div>
</div>
{/* 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">
<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>
<div className="relative hyphens-auto text-justify font-medium">
) : (
<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>
{isEditingDescription ? (
<div className="relative flex gap-2 mt-1">
<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>
{/* Reading Statistics */}
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<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>
{/* Metadata Section */}
{/* TODO: Add metadata component when available */}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState, FormEvent, useRef } from 'react';
import { Link } from 'react-router-dom';
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 { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters';
@@ -64,14 +64,14 @@ function DocumentCard({ doc }: DocumentCardProps) {
</div>
<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}`}>
<Activity size={20} />
<ActivityIcon size={20} />
</Link>
{doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`}>
<Download size={20} />
<DownloadIcon size={20} />
</a>
) : (
<Download size={20} className="text-gray-400" />
<DownloadIcon size={20} disabled />
)}
</div>
</div>
@@ -140,7 +140,7 @@ export default function DocumentsPage() {
<div className="flex w-full grow flex-col">
<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">
<Search size={15} />
<SearchIcon size={15} />
</span>
<input
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"
htmlFor="upload-file-button"
>
<Upload size={34} />
<UploadIcon size={34} />
</label>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
import { useGetHome } from '../generated/anthoLumeAPIV1';
import type { LeaderboardData } from '../generated/model';
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
import { formatNumber, formatDuration } from '../utils/formatters';
@@ -191,15 +191,13 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
export default function HomePage() {
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 streaks = homeData?.data?.streaks?.streaks;
const graphData = homeData?.data?.graph_data?.graph_data;
const userStats = homeData?.data?.user_statistics;
if (homeLoading || docsLoading) {
if (homeLoading) {
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: [] }}
/>
</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>
);
}

View File

@@ -1,7 +1,8 @@
import { useState, FormEvent } from 'react';
import { useGetSearch } from '../generated/anthoLumeAPIV1';
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';
export default function SearchPage() {
@@ -25,7 +26,7 @@ export default function SearchPage() {
<div className="flex w-full grow flex-col">
<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">
<Search size={15} />
<SearchIcon size={15} />
</span>
<input
type="text"
@@ -101,7 +102,7 @@ export default function SearchPage() {
<tr key={item.id}>
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
<button className="hover:text-purple-600" title="Download">
<Download size={15} />
<DownloadIcon size={15} />
</button>
</td>
<td className="border-b border-gray-200 p-3">

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, FormEvent } from 'react';
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 { useToasts } from '../components/ToastContext';
@@ -108,7 +108,7 @@ export default function SettingsPage() {
{/* User Profile Card */}
<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">
<User size={60} />
<UserIcon size={60} />
<p className="text-lg">{settingsData?.data.user.username || 'N/A'}</p>
</div>
</div>
@@ -121,7 +121,7 @@ export default function SettingsPage() {
<div className="flex grow flex-col">
<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">
<Lock size={15} />
<PasswordIcon size={15} />
</span>
<input
type="password"
@@ -135,7 +135,7 @@ export default function SettingsPage() {
<div className="flex grow flex-col">
<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">
<Lock size={15} />
<PasswordIcon size={15} />
</span>
<input
type="password"
@@ -160,7 +160,7 @@ export default function SettingsPage() {
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
<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">
<Clock size={15} />
<ClockIcon size={15} />
</span>
<select
value={timezone || 'UTC'}

View File

@@ -1,6 +1,7 @@
package metadata
import (
"context"
"encoding/json"
"errors"
"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"
func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
return getGBooksMetadataWithContext(context.Background(), metadataSearch)
}
func getGBooksMetadataWithContext(ctx context.Context, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
var queryResults []gBooksQueryItem
if metadataSearch.ID != nil {
// Use GBID
resp, err := performGBIDRequest(*metadataSearch.ID)
resp, err := performGBIDRequestWithContext(ctx, *metadataSearch.ID)
if err != nil {
return nil, err
}
@@ -51,7 +56,7 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
queryResults = []gBooksQueryItem{*resp}
} else if metadataSearch.ISBN13 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN13
resp, err := performSearchRequest(searchQuery)
resp, err := performSearchRequestWithContext(ctx, searchQuery)
if err != nil {
return nil, err
}
@@ -59,7 +64,7 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
queryResults = resp.Items
} else if metadataSearch.ISBN10 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN10
resp, err := performSearchRequest(searchQuery)
resp, err := performSearchRequestWithContext(ctx, searchQuery)
if err != nil {
return nil, err
}
@@ -76,7 +81,7 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
// Escape & Trim
searchQuery = url.QueryEscape(strings.TrimSpace(searchQuery))
resp, err := performSearchRequest(searchQuery)
resp, err := performSearchRequestWithContext(ctx, searchQuery)
if err != nil {
return nil, err
}
@@ -119,6 +124,10 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, 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
_, err := os.Stat(coverFilePath)
if err == nil && !overwrite {
@@ -137,7 +146,14 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
// Download File
log.Info("Downloading Cover")
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 {
log.Error("Cover URL 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) {
return performSearchRequestWithContext(context.Background(), searchQuery)
}
func performSearchRequestWithContext(ctx context.Context, searchQuery string) (*gBooksQueryResponse, error) {
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
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 {
log.Error("Google Books Query URL API Failure")
return nil, errors.New("API Failure")
@@ -166,6 +193,7 @@ func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
parsedResp := gBooksQueryResponse{}
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
resp.Body.Close()
if err != nil {
log.Error("Google Books Query API Decode Failure")
return nil, errors.New("API Failure")
@@ -180,10 +208,21 @@ func performSearchRequest(searchQuery string) (*gBooksQueryResponse, 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)
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 {
log.Error("Cover URL API Failure")
return nil, errors.New("API Failure")
@@ -191,6 +230,7 @@ func performGBIDRequest(id string) (*gBooksQueryItem, error) {
parsedResp := gBooksQueryItem{}
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
resp.Body.Close()
if err != nil {
log.Error("Google Books ID API Decode Failure")
return nil, errors.New("API Failure")

View File

@@ -1,6 +1,7 @@
package metadata
import (
"context"
"errors"
"fmt"
"io"
@@ -45,12 +46,17 @@ type MetadataInfo struct {
// 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) {
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
coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID))
coverFilePath := filepath.Join(coverDir, coverFile)
// Save Google Books
if err := saveGBooksCover(gbid, coverFilePath, overwrite); err != nil {
if err := saveGBooksCoverWithContext(ctx, gbid, coverFilePath, overwrite); err != nil {
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.
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 {
case SOURCE_GBOOK:
return getGBooksMetadata(metadataSearch)
return getGBooksMetadataWithContext(ctx, metadataSearch)
case SOURCE_OLIB:
return nil, errors.New("not implemented")
default: