diff --git a/AGENTS.md b/AGENTS.md index 9fd02b3..85ce3c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: - - `api/v1/api.gen.go` - - `frontend/src/generated/**/*.ts` +- **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` diff --git a/antholume b/antholume new file mode 100755 index 0000000..94febc5 Binary files /dev/null and b/antholume differ diff --git a/api/v1/activity.go b/api/v1/activity.go index a8123d6..2db2c6e 100644 --- a/api/v1/activity.go +++ b/api/v1/activity.go @@ -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 } diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go index 0c4fe34..3c41923 100644 --- a/api/v1/api.gen.go +++ b/api/v1/api.gen.go @@ -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 diff --git a/api/v1/auth.go b/api/v1/auth.go index 57c87f6..31902d4 100644 --- a/api/v1/auth.go +++ b/api/v1/auth.go @@ -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) diff --git a/api/v1/documents.go b/api/v1/documents.go index 9c15eb3..295c20e 100644 --- a/api/v1/documents.go +++ b/api/v1/documents.go @@ -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 diff --git a/api/v1/home.go b/api/v1/home.go index 4d86504..787755a 100644 --- a/api/v1/home.go +++ b/api/v1/home.go @@ -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 } diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index e15353a..a07790a 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -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: diff --git a/api/v1/progress.go b/api/v1/progress.go index b6876b0..f5a694e 100644 --- a/api/v1/progress.go +++ b/api/v1/progress.go @@ -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 diff --git a/api/v1/server.go b/api/v1/server.go index 830970c..10c084a 100644 --- a/api/v1/server.go +++ b/api/v1/server.go @@ -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) diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 42ccccd..9d8e268 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -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, }); diff --git a/frontend/src/components/HamburgerMenu.tsx b/frontend/src/components/HamburgerMenu.tsx index fd5fe22..94e4657 100644 --- a/frontend/src/components/HamburgerMenu.tsx +++ b/frontend/src/components/HamburgerMenu.tsx @@ -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' }`} > - + Admin diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 01c00a7..4157521 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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" > - + {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" > - {userData?.username || 'User'} + {userData ? ('username' in userData ? userData.username : 'User') : 'User'} >, +/** + * @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 => { + + 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 = (options?: { mutation?:UseMutationOptions>, TError,{id: string;data: EditDocumentBody}, TContext>, fetch?: RequestInit} +): UseMutationOptions>, 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>, {id: string;data: EditDocumentBody}> = (props) => { + const {id,data} = props ?? {}; + + return editDocument(id,data,fetchOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type EditDocumentMutationResult = NonNullable>> + export type EditDocumentMutationBody = EditDocumentBody + export type EditDocumentMutationError = ErrorResponse + + /** + * @summary Update document editable fields + */ +export const useEditDocument = (options?: { mutation?:UseMutationOptions>, TError,{id: string;data: EditDocumentBody}, TContext>, fetch?: RequestInit} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {id: string;data: EditDocumentBody}, + TContext + > => { + return useMutation(getEditDocumentMutationOptions(options), queryClient); + } + /** * @summary Get document cover image */ @@ -581,6 +695,120 @@ export function useGetDocumentCover { + + + + + return `/api/v1/documents/${id}/cover` +} + +export const uploadDocumentCover = async (id: string, + uploadDocumentCoverBody: UploadDocumentCoverBody, options?: RequestInit): Promise => { + 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 = (options?: { mutation?:UseMutationOptions>, TError,{id: string;data: UploadDocumentCoverBody}, TContext>, fetch?: RequestInit} +): UseMutationOptions>, 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>, {id: string;data: UploadDocumentCoverBody}> = (props) => { + const {id,data} = props ?? {}; + + return uploadDocumentCover(id,data,fetchOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type UploadDocumentCoverMutationResult = NonNullable>> + export type UploadDocumentCoverMutationBody = UploadDocumentCoverBody + export type UploadDocumentCoverMutationError = ErrorResponse + + /** + * @summary Upload document cover image + */ +export const useUploadDocumentCover = (options?: { mutation?:UseMutationOptions>, TError,{id: string;data: UploadDocumentCoverBody}, TContext>, fetch?: RequestInit} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {id: string;data: UploadDocumentCoverBody}, + TContext + > => { + return useMutation(getUploadDocumentCoverMutationOptions(options), queryClient); + } + /** * @summary Download document file */ diff --git a/frontend/src/generated/model/activityResponse.ts b/frontend/src/generated/model/activityResponse.ts index f26caf3..14032e6 100644 --- a/frontend/src/generated/model/activityResponse.ts +++ b/frontend/src/generated/model/activityResponse.ts @@ -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; } diff --git a/frontend/src/generated/model/documentResponse.ts b/frontend/src/generated/model/documentResponse.ts index b2382d8..25c1bd5 100644 --- a/frontend/src/generated/model/documentResponse.ts +++ b/frontend/src/generated/model/documentResponse.ts @@ -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; } diff --git a/frontend/src/generated/model/editDocumentBody.ts b/frontend/src/generated/model/editDocumentBody.ts new file mode 100644 index 0000000..99834c6 --- /dev/null +++ b/frontend/src/generated/model/editDocumentBody.ts @@ -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; +}; diff --git a/frontend/src/generated/model/graphDataResponse.ts b/frontend/src/generated/model/graphDataResponse.ts index 523c226..bdee228 100644 --- a/frontend/src/generated/model/graphDataResponse.ts +++ b/frontend/src/generated/model/graphDataResponse.ts @@ -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; } diff --git a/frontend/src/generated/model/homeResponse.ts b/frontend/src/generated/model/homeResponse.ts index 0c7d145..37b28d1 100644 --- a/frontend/src/generated/model/homeResponse.ts +++ b/frontend/src/generated/model/homeResponse.ts @@ -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; } diff --git a/frontend/src/generated/model/index.ts b/frontend/src/generated/model/index.ts index df123c9..8b37270 100644 --- a/frontend/src/generated/model/index.ts +++ b/frontend/src/generated/model/index.ts @@ -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'; diff --git a/frontend/src/generated/model/progressListResponse.ts b/frontend/src/generated/model/progressListResponse.ts index 7a3ab2a..770e293 100644 --- a/frontend/src/generated/model/progressListResponse.ts +++ b/frontend/src/generated/model/progressListResponse.ts @@ -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; diff --git a/frontend/src/generated/model/progressResponse.ts b/frontend/src/generated/model/progressResponse.ts index 29c7014..6e45bae 100644 --- a/frontend/src/generated/model/progressResponse.ts +++ b/frontend/src/generated/model/progressResponse.ts @@ -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; } diff --git a/frontend/src/generated/model/streaksResponse.ts b/frontend/src/generated/model/streaksResponse.ts index 5104f2e..0492a9a 100644 --- a/frontend/src/generated/model/streaksResponse.ts +++ b/frontend/src/generated/model/streaksResponse.ts @@ -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; } diff --git a/frontend/src/generated/model/updateDocumentBody.ts b/frontend/src/generated/model/updateDocumentBody.ts new file mode 100644 index 0000000..6016673 --- /dev/null +++ b/frontend/src/generated/model/updateDocumentBody.ts @@ -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; +}; diff --git a/frontend/src/generated/model/uploadDocumentCoverBody.ts b/frontend/src/generated/model/uploadDocumentCoverBody.ts new file mode 100644 index 0000000..7b9aff9 --- /dev/null +++ b/frontend/src/generated/model/uploadDocumentCoverBody.ts @@ -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; +}; diff --git a/frontend/src/generated/model/userStatisticsResponse.ts b/frontend/src/generated/model/userStatisticsResponse.ts index cec8558..888f950 100644 --- a/frontend/src/generated/model/userStatisticsResponse.ts +++ b/frontend/src/generated/model/userStatisticsResponse.ts @@ -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; } diff --git a/frontend/src/icons/ActivityIcon.tsx b/frontend/src/icons/ActivityIcon.tsx new file mode 100644 index 0000000..f60572d --- /dev/null +++ b/frontend/src/icons/ActivityIcon.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/src/icons/AddIcon.tsx b/frontend/src/icons/AddIcon.tsx new file mode 100644 index 0000000..ae09e0f --- /dev/null +++ b/frontend/src/icons/AddIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/BaseIcon.tsx b/frontend/src/icons/BaseIcon.tsx new file mode 100644 index 0000000..06a9865 --- /dev/null +++ b/frontend/src/icons/BaseIcon.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/src/icons/ClockIcon.tsx b/frontend/src/icons/ClockIcon.tsx new file mode 100644 index 0000000..f092b63 --- /dev/null +++ b/frontend/src/icons/ClockIcon.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/src/icons/DeleteIcon.tsx b/frontend/src/icons/DeleteIcon.tsx new file mode 100644 index 0000000..1f8d675 --- /dev/null +++ b/frontend/src/icons/DeleteIcon.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/src/icons/DocumentsIcon.tsx b/frontend/src/icons/DocumentsIcon.tsx new file mode 100644 index 0000000..cd1f8db --- /dev/null +++ b/frontend/src/icons/DocumentsIcon.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/src/icons/DownloadIcon.tsx b/frontend/src/icons/DownloadIcon.tsx new file mode 100644 index 0000000..0cce47a --- /dev/null +++ b/frontend/src/icons/DownloadIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/DropdownIcon.tsx b/frontend/src/icons/DropdownIcon.tsx new file mode 100644 index 0000000..9abd616 --- /dev/null +++ b/frontend/src/icons/DropdownIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/EditIcon.tsx b/frontend/src/icons/EditIcon.tsx new file mode 100644 index 0000000..c166c1c --- /dev/null +++ b/frontend/src/icons/EditIcon.tsx @@ -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 ( + + + + + + ); +} diff --git a/frontend/src/icons/HomeIcon.tsx b/frontend/src/icons/HomeIcon.tsx new file mode 100644 index 0000000..ae49ce5 --- /dev/null +++ b/frontend/src/icons/HomeIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/ImportIcon.tsx b/frontend/src/icons/ImportIcon.tsx new file mode 100644 index 0000000..9bad7e6 --- /dev/null +++ b/frontend/src/icons/ImportIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/InfoIcon.tsx b/frontend/src/icons/InfoIcon.tsx new file mode 100644 index 0000000..642da51 --- /dev/null +++ b/frontend/src/icons/InfoIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/LoadingIcon.tsx b/frontend/src/icons/LoadingIcon.tsx new file mode 100644 index 0000000..1f8389d --- /dev/null +++ b/frontend/src/icons/LoadingIcon.tsx @@ -0,0 +1,56 @@ +interface LoadingIconProps { + size?: number; + className?: string; +} + +export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) { + return ( + + + + + + + ); +} diff --git a/frontend/src/icons/PasswordIcon.tsx b/frontend/src/icons/PasswordIcon.tsx new file mode 100644 index 0000000..023ec07 --- /dev/null +++ b/frontend/src/icons/PasswordIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/Search2Icon.tsx b/frontend/src/icons/Search2Icon.tsx new file mode 100644 index 0000000..a64e143 --- /dev/null +++ b/frontend/src/icons/Search2Icon.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/src/icons/SearchIcon.tsx b/frontend/src/icons/SearchIcon.tsx new file mode 100644 index 0000000..8d11774 --- /dev/null +++ b/frontend/src/icons/SearchIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/SettingsIcon.tsx b/frontend/src/icons/SettingsIcon.tsx new file mode 100644 index 0000000..d2dce28 --- /dev/null +++ b/frontend/src/icons/SettingsIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/UploadIcon.tsx b/frontend/src/icons/UploadIcon.tsx new file mode 100644 index 0000000..1c309b8 --- /dev/null +++ b/frontend/src/icons/UploadIcon.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/src/icons/UserIcon.tsx b/frontend/src/icons/UserIcon.tsx new file mode 100644 index 0000000..333478b --- /dev/null +++ b/frontend/src/icons/UserIcon.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/icons/index.ts b/frontend/src/icons/index.ts new file mode 100644 index 0000000..a559e95 --- /dev/null +++ b/frontend/src/icons/index.ts @@ -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'; diff --git a/frontend/src/pages/AdminLogsPage.tsx b/frontend/src/pages/AdminLogsPage.tsx index 9ecdb14..376f6e7 100644 --- a/frontend/src/pages/AdminLogsPage.tsx +++ b/frontend/src/pages/AdminLogsPage.tsx @@ -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() {
- + @@ -192,7 +192,7 @@ export default function AdminUsersPage() { {/* Delete Button */} {/* User ID */} diff --git a/frontend/src/pages/DocumentPage.tsx b/frontend/src/pages/DocumentPage.tsx index cd999fa..00c4dde 100644 --- a/frontend/src/pages/DocumentPage.tsx +++ b/frontend/src/pages/DocumentPage.tsx @@ -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
Loading...
; } - 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
Document not found
; + } + + const document = docData.data.document as Document; + const progress = + progressData?.status === 200 ? (progressData.data.progress as Progress | undefined) : undefined; if (!document) { return
Document not found
; @@ -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 ( -
-
+
+
{/* Document Info - Left Column */}
- {/* Cover Image */} - {document.filepath && ( -
- {`${document.title} -
- )} + {/* Cover Image with Edit Label */} + {/* Read Button - Only if file exists */} {document.filepath && ( @@ -82,8 +146,9 @@ export default function DocumentPage() { )} - {/* Action Buttons */} -
+ {/* Action Buttons Container */} +
+ {/* ISBN Info */}

ISBN-10:

@@ -95,112 +160,375 @@ export default function DocumentPage() {
- {/* Download Button - Only if file exists */} - {document.filepath && ( + {/* Icons Container */} +
+ {/* Edit Cover Dropdown */} +
+ setShowEditCover(e.target.checked)} + /> +
+
+ + +
+
+ + +
+
+
+ + {/* Delete Button */} +
+ +
+
+ +
+
+
+ + {/* Activity Button */} - - - + - )} + + {/* Identify/Search Button */} +
+ +
+
+ + + + +
+
+
+ + {/* Download Button */} + {document.filepath ? ( + + + + ) : ( + + + + )} +
{/* Document Details Grid */}
{/* Title - Editable */} -
+

Title

+ {isEditingTitle ? ( +
+ + +
+ ) : ( + + )}
-
-

{document.title}

-
+ {isEditingTitle ? ( +
+ 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" + /> +
+ ) : ( +

{document.title}

+ )}
{/* Author - Editable */} -
+

Author

+ {isEditingAuthor ? ( +
+ + +
+ ) : ( + + )}
-
-

{document.author}

-
+ {isEditingAuthor ? ( +
+ 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" + /> +
+ ) : ( +

{document.author}

+ )}
- {/* Time Read */} + {/* Time Read with Info Dropdown */}

Time Read

+ +
+
+

Seconds / Percent

+

+ {secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'} +

+
+
+

Words / Minute

+

+ {document.wpm && document.wpm > 0 ? document.wpm : 'N/A'} +

+
+
+

Est. Time Left

+

+ {totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'} +

+
+
-
-

- {document.total_time_seconds ? formatDuration(document.total_time_seconds) : 'N/A'} -

-
+

+ {document.total_time_seconds && document.total_time_seconds > 0 + ? formatDuration(document.total_time_seconds) + : 'N/A'} +

{/* Progress */}

Progress

-

+

{percentage ? `${Math.round(percentage)}%` : '0%'}

{/* Description - Editable */} -
+

Description

+ {isEditingDescription ? ( +
+ + +
+ ) : ( + + )}
-
-

{document.description || 'N/A'}

-
+ {isEditingDescription ? ( +
+