From 6d538ed15419d898482f94da4de7da1067974817 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 1 Nov 2025 21:17:42 +0300 Subject: [PATCH 01/97] feat: common code for back functions are now in handlers/common.go --- modules/backend/handlers/common.go | 19 +++++++++++++++++++ modules/backend/handlers/users.go | 21 ++++++++++----------- 2 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 modules/backend/handlers/common.go diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go new file mode 100644 index 0000000..b85d022 --- /dev/null +++ b/modules/backend/handlers/common.go @@ -0,0 +1,19 @@ +package handlers + +import ( + sqlc "nyanimedb/sql" + "strconv" +) + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index b67153d..3da61de 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -4,24 +4,23 @@ import ( "context" oapi "nyanimedb/api" sqlc "nyanimedb/sql" - "strconv" "github.com/jackc/pgx/v5" "github.com/oapi-codegen/runtime/types" ) -type Server struct { - db *sqlc.Queries -} +// type Server struct { +// db *sqlc.Queries +// } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} -} +// func NewServer(db *sqlc.Queries) Server { +// return Server{db: db} +// } -func parseInt64(s string) (int32, error) { - i, err := strconv.ParseInt(s, 10, 64) - return int32(i), err -} +// func parseInt64(s string) (int32, error) { +// i, err := strconv.ParseInt(s, 10, 64) +// return int32(i), err +// } func mapUser(u sqlc.GetUserByIDRow) oapi.User { return oapi.User{ From 4ffa7dc93e7a06f39260a27b1ee5b0d574d4e5be Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Wed, 5 Nov 2025 17:11:43 +0300 Subject: [PATCH 02/97] feat: add new user handler was uncommented --- api/openapi.yaml | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index b20f677..6439c86 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -196,7 +196,7 @@ paths: # error: # type: string - # /users: + /users: # get: # summary: Search user # parameters: @@ -218,28 +218,28 @@ paths: # items: # $ref: '#/components/schemas/User' - # post: - # summary: Add new user - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Add result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - # user_json: - # $ref: '#/components/schemas/User' + post: + summary: Add new user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Add result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + user_json: + $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: @@ -580,7 +580,7 @@ components: required: - user_id - nickname - - creation_date + # - creation_date UserTitle: type: object additionalProperties: true From bac889b6276c6cb5a9f6a8b136922ab434a2e442 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Wed, 5 Nov 2025 17:20:09 +0300 Subject: [PATCH 03/97] fix: regexps for mail and nickname were corrected --- sql/migrations/000001_init.up.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 0b7fa33..eba22e2 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -27,8 +27,8 @@ CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, avatar_id bigint REFERENCES images (id), passhash text NOT NULL, - mail text CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), - nickname text NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), + mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), + nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), disp_name text, user_desc text, creation_date timestamptz NOT NULL, From 83fee98059ced5aaee9a1109ccb5129cf47fc360 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 14 Nov 2025 15:22:14 +0300 Subject: [PATCH 04/97] feat: external_ids table is added for binding user sessions in tg and other services --- sql/migrations/000001_init.up.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index eba22e2..abecd32 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -85,6 +85,12 @@ CREATE TABLE signals ( pending boolean NOT NULL ); +CREATE TABLE external_ids ( + user_id NOT NULL REFERENCES users (id), + service_id text NOT NULL, + external_ids text NOT NULL +); + -- Functions CREATE OR REPLACE FUNCTION update_title_rating() RETURNS TRIGGER AS $$ From f24edc5dd76f1bedfece5c173203d461d9971e91 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 14 Nov 2025 15:34:48 +0300 Subject: [PATCH 05/97] feat: external_services table create --- sql/migrations/000001_init.up.sql | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index abecd32..a5e89b8 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -87,8 +87,13 @@ CREATE TABLE signals ( CREATE TABLE external_ids ( user_id NOT NULL REFERENCES users (id), - service_id text NOT NULL, - external_ids text NOT NULL + service_id bigint REFERENCES external_services (id), + external_id text NOT NULL +); + +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL ); -- Functions From 7fed5ed53665466a8da575a0a9b83978ecc85c7b Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 14 Nov 2025 23:57:34 +0300 Subject: [PATCH 06/97] feat: get titles added with all components needed --- api/openapi.yaml | 151 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 117 insertions(+), 34 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 6439c86..c1aed30 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5,40 +5,60 @@ info: servers: - url: /api/v1 paths: - # /title: - # get: - # summary: Get titles - # parameters: - # - in: query - # name: query - # schema: - # type: string - # - in: query - # name: limit - # schema: - # type: integer - # default: 10 - # - in: query - # name: offset - # schema: - # type: integer - # default: 0 - # - in: query - # name: fields - # schema: - # type: string - # default: all - # responses: - # '200': - # description: List of titles - # content: - # application/json: - # schema: - # type: array - # items: - # $ref: '#/components/schemas/Title' - # '204': - # description: No titles found + /title: + get: + summary: Get titles + parameters: + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + default: 10 + - in: query + name: offset + schema: + type: integer + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Title' + '204': + description: No titles found + '500': + description: Unknown server error # /title/{title_id}: # get: @@ -535,8 +555,71 @@ paths: components: schemas: + TitleStatus: + type: string + description: Title status + enum: + - finished + - ongoing + - planned + ReleaseSeason: + type: string + description: Title release season + enum: + - winter + - spring + - summer + - fall + UserTitleStatus: + type: string + description: User's title status + enum: + - finished + - planned + - dropped + - in-progress Title: type: object + properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: array + items: + type: string + studio_id: + type: integer + format: int64 + poster_id: + type: integer + format: int64 + title_status: + $ref: '#/components/schemas/TitleStatus' + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: '#/components/schemas/ReleaseSeason' + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: array + items: + type: number + format: double additionalProperties: true User: type: object From 765e75e8bba8808abb08dc7f9209a06771d00249 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 00:04:43 +0300 Subject: [PATCH 07/97] feat: new responses added --- api/openapi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index c1aed30..b17b539 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -57,6 +57,8 @@ paths: $ref: '#/components/schemas/Title' '204': description: No titles found + '400': + description: Request params are not correct '500': description: Unknown server error @@ -167,6 +169,10 @@ paths: $ref: '#/components/schemas/User' '404': description: User not found + '400': + description: Request params are not correct + '500': + description: Unknown server error # patch: # summary: Update user From c2dc7627002b56e1ad92f6aff41b26e0ea63608b Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 00:46:47 +0300 Subject: [PATCH 08/97] feat: openapi changes --- api/api.gen.go | 517 +++++++++++++++++++++++++++++++++++- go.mod | 1 + go.sum | 3 + modules/backend/queries.sql | 39 ++- 4 files changed, 553 insertions(+), 7 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 24aebd3..40c0fa2 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,13 +16,57 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for ReleaseSeason. +const ( + Fall ReleaseSeason = "fall" + Spring ReleaseSeason = "spring" + Summer ReleaseSeason = "summer" + Winter ReleaseSeason = "winter" +) + +// Defines values for TitleStatus. +const ( + Finished TitleStatus = "finished" + Ongoing TitleStatus = "ongoing" + Planned TitleStatus = "planned" +) + +// ReleaseSeason Title release season +type ReleaseSeason string + +// Title defines model for Title. +type Title struct { + EpisodesAired *int32 `json:"episodes_aired,omitempty"` + EpisodesAll *int32 `json:"episodes_all,omitempty"` + EpisodesLen *[]float64 `json:"episodes_len,omitempty"` + + // Id Unique title ID (primary key) + Id *int64 `json:"id,omitempty"` + PosterId *int64 `json:"poster_id,omitempty"` + Rating *float64 `json:"rating,omitempty"` + RatingCount *int32 `json:"rating_count,omitempty"` + + // ReleaseSeason Title release season + ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` + ReleaseYear *int32 `json:"release_year,omitempty"` + StudioId *int64 `json:"studio_id,omitempty"` + TitleNames *[]string `json:"title_names,omitempty"` + + // TitleStatus Title status + TitleStatus *TitleStatus `json:"title_status,omitempty"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// TitleStatus Title status +type TitleStatus string + // User defines model for User. type User struct { // AvatarId ID of the user avatar (references images table) AvatarId *int64 `json:"avatar_id"` // CreationDate Timestamp when the user was created - CreationDate time.Time `json:"creation_date"` + CreationDate *time.Time `json:"creation_date,omitempty"` // DispName Display name DispName *string `json:"disp_name,omitempty"` @@ -40,13 +84,267 @@ type User struct { UserDesc *string `json:"user_desc,omitempty"` } +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { + Word *string `form:"word,omitempty" json:"word,omitempty"` + Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + // GetUsersUserIdParams defines parameters for GetUsersUserId. type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. +type PostUsersJSONRequestBody = User + +// Getter for additional properties for Title. Returns the specified +// element and whether it was found +func (a Title) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for Title +func (a *Title) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a *Title) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["episodes_aired"]; found { + err = json.Unmarshal(raw, &a.EpisodesAired) + if err != nil { + return fmt.Errorf("error reading 'episodes_aired': %w", err) + } + delete(object, "episodes_aired") + } + + if raw, found := object["episodes_all"]; found { + err = json.Unmarshal(raw, &a.EpisodesAll) + if err != nil { + return fmt.Errorf("error reading 'episodes_all': %w", err) + } + delete(object, "episodes_all") + } + + if raw, found := object["episodes_len"]; found { + err = json.Unmarshal(raw, &a.EpisodesLen) + if err != nil { + return fmt.Errorf("error reading 'episodes_len': %w", err) + } + delete(object, "episodes_len") + } + + if raw, found := object["id"]; found { + err = json.Unmarshal(raw, &a.Id) + if err != nil { + return fmt.Errorf("error reading 'id': %w", err) + } + delete(object, "id") + } + + if raw, found := object["poster_id"]; found { + err = json.Unmarshal(raw, &a.PosterId) + if err != nil { + return fmt.Errorf("error reading 'poster_id': %w", err) + } + delete(object, "poster_id") + } + + if raw, found := object["rating"]; found { + err = json.Unmarshal(raw, &a.Rating) + if err != nil { + return fmt.Errorf("error reading 'rating': %w", err) + } + delete(object, "rating") + } + + if raw, found := object["rating_count"]; found { + err = json.Unmarshal(raw, &a.RatingCount) + if err != nil { + return fmt.Errorf("error reading 'rating_count': %w", err) + } + delete(object, "rating_count") + } + + if raw, found := object["release_season"]; found { + err = json.Unmarshal(raw, &a.ReleaseSeason) + if err != nil { + return fmt.Errorf("error reading 'release_season': %w", err) + } + delete(object, "release_season") + } + + if raw, found := object["release_year"]; found { + err = json.Unmarshal(raw, &a.ReleaseYear) + if err != nil { + return fmt.Errorf("error reading 'release_year': %w", err) + } + delete(object, "release_year") + } + + if raw, found := object["studio_id"]; found { + err = json.Unmarshal(raw, &a.StudioId) + if err != nil { + return fmt.Errorf("error reading 'studio_id': %w", err) + } + delete(object, "studio_id") + } + + if raw, found := object["title_names"]; found { + err = json.Unmarshal(raw, &a.TitleNames) + if err != nil { + return fmt.Errorf("error reading 'title_names': %w", err) + } + delete(object, "title_names") + } + + if raw, found := object["title_status"]; found { + err = json.Unmarshal(raw, &a.TitleStatus) + if err != nil { + return fmt.Errorf("error reading 'title_status': %w", err) + } + delete(object, "title_status") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a Title) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.EpisodesAired != nil { + object["episodes_aired"], err = json.Marshal(a.EpisodesAired) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_aired': %w", err) + } + } + + if a.EpisodesAll != nil { + object["episodes_all"], err = json.Marshal(a.EpisodesAll) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_all': %w", err) + } + } + + if a.EpisodesLen != nil { + object["episodes_len"], err = json.Marshal(a.EpisodesLen) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_len': %w", err) + } + } + + if a.Id != nil { + object["id"], err = json.Marshal(a.Id) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) + } + } + + if a.PosterId != nil { + object["poster_id"], err = json.Marshal(a.PosterId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'poster_id': %w", err) + } + } + + if a.Rating != nil { + object["rating"], err = json.Marshal(a.Rating) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating': %w", err) + } + } + + if a.RatingCount != nil { + object["rating_count"], err = json.Marshal(a.RatingCount) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating_count': %w", err) + } + } + + if a.ReleaseSeason != nil { + object["release_season"], err = json.Marshal(a.ReleaseSeason) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_season': %w", err) + } + } + + if a.ReleaseYear != nil { + object["release_year"], err = json.Marshal(a.ReleaseYear) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_year': %w", err) + } + } + + if a.StudioId != nil { + object["studio_id"], err = json.Marshal(a.StudioId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'studio_id': %w", err) + } + } + + if a.TitleNames != nil { + object["title_names"], err = json.Marshal(a.TitleNames) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_names': %w", err) + } + } + + if a.TitleStatus != nil { + object["title_status"], err = json.Marshal(a.TitleStatus) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_status': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // ServerInterface represents all server handlers. type ServerInterface interface { + // Get titles + // (GET /title) + GetTitle(c *gin.Context, params GetTitleParams) + // Add new user + // (POST /users) + PostUsers(c *gin.Context) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -61,6 +359,101 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleParams + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitle(c, params) +} + +// PostUsers operation middleware +func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostUsers(c) +} + // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -123,9 +516,65 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.POST(options.BaseURL+"/users", wrapper.PostUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } +type GetTitleRequestObject struct { + Params GetTitleParams +} + +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error +} + +type GetTitle200JSONResponse []Title + +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitle204Response struct { +} + +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitle500Response struct { +} + +func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type PostUsersRequestObject struct { + Body *PostUsersJSONRequestBody +} + +type PostUsersResponseObject interface { + VisitPostUsersResponse(w http.ResponseWriter) error +} + +type PostUsers200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` + UserJson *User `json:"user_json,omitempty"` +} + +func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -154,6 +603,12 @@ func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.Res // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get titles + // (GET /title) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Add new user + // (POST /users) + PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -171,6 +626,66 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { + var request GetTitleRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostUsers operation middleware +func (sh *strictHandler) PostUsers(ctx *gin.Context) { + var request PostUsersRequestObject + + var body PostUsersJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostUsersResponseObject); ok { + if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/go.mod b/go.mod index b7a66f2..7c34aeb 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index 1af1a7c..121ca40 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -95,6 +97,7 @@ golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b1dd8af..b90ec6a 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -38,12 +38,39 @@ WHERE id = $1; -- DELETE FROM users -- WHERE user_id = $1; --- -- name: GetTitleByID :one --- SELECT title_id, title_names, studio_id, poster_id, signal_ids, --- title_status, rating, rating_count, release_year, release_season, --- season, episodes_aired, episodes_all, episodes_len --- FROM titles --- WHERE title_id = $1; +-- name: SearchTitles :many +SELECT + * +FROM titles +WHERE + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) + AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) + +LIMIT COALESCE(sqlc.narg('limit')::int, 100) -- 100 is default limit +OFFSET sqlc.narg('offset')::int; -- -- name: ListTitles :many -- SELECT title_id, title_names, studio_id, poster_id, signal_ids, From d2450ffc89d1ee16311c140c3422dea8f166745f Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 00:52:23 +0300 Subject: [PATCH 09/97] feat: titles.go added --- api/api.gen.go | 24 ++++++++ api/openapi.yaml | 10 ++++ modules/backend/handlers/titles.go | 96 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 modules/backend/handlers/titles.go diff --git a/api/api.gen.go b/api/api.gen.go index 40c0fa2..d17b591 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -546,6 +546,14 @@ func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } +type GetTitle400Response struct { +} + +func (response GetTitle400Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + type GetTitle500Response struct { } @@ -593,6 +601,14 @@ func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http return json.NewEncoder(w).Encode(response) } +type GetUsersUserId400Response struct { +} + +func (response GetUsersUserId400Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + type GetUsersUserId404Response struct { } @@ -601,6 +617,14 @@ func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type GetUsersUserId500Response struct { +} + +func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles diff --git a/api/openapi.yaml b/api/openapi.yaml index b17b539..43e380d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -10,6 +10,7 @@ paths: summary: Get titles parameters: - in: query +<<<<<<< Updated upstream name: word schema: type: string @@ -32,6 +33,12 @@ paths: schema: $ref: '#/components/schemas/ReleaseSeason' - in: query +======= + name: query + schema: + type: string + - in: query +>>>>>>> Stashed changes name: limit schema: type: integer @@ -57,10 +64,13 @@ paths: $ref: '#/components/schemas/Title' '204': description: No titles found +<<<<<<< Updated upstream '400': description: Request params are not correct '500': description: Unknown server error +======= +>>>>>>> Stashed changes # /title/{title_id}: # get: diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go new file mode 100644 index 0000000..7bfcf58 --- /dev/null +++ b/modules/backend/handlers/titles.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "context" + "fmt" + oapi "nyanimedb/api" + sqlc "nyanimedb/sql" + + log "github.com/sirupsen/logrus" +) + +func Word2Sqlc(s *string) *string { + if s == nil { + return nil + } + if *s == "" { + return nil + } + return s +} + +func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { + if s == nil { + return nil, nil + } + var t sqlc.TitleStatusT + if *s == "finished" { + t = "finished" + } else if *s == "ongoing" { + t = "ongoing" + } else if *s == "planned" { + t = "planned" + } else { + return nil, fmt.Errorf("unexpected tittle status: %s", *s) + } + return &t, nil +} + +func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { + if s == nil { + return nil, nil + } + var t sqlc.ReleaseSeasonT + if *s == "winter" { + t = "winter" + } else if *s == "spring" { + t = "spring" + } else if *s == "summer" { + t = "summer" + } else if *s == "fall" { + t = "fall" + } else { + return nil, fmt.Errorf("unexpected release season: %s", *s) + } + return &t, nil +} + +func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { + var result []oapi.Title + + word := Word2Sqlc(request.Params.Word) + status, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle400Response{}, err + } + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle400Response{}, err + } + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ + Word: word, + Status: status, + Rating: request.Params.Rating, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + }) + if err != nil { + return oapi.GetTitle500Response{}, nil + } + if len(titles) == 0 { + return oapi.GetTitle204Response{}, nil + } + + for _, title := range titles { + t := oapi.Title{ + Rating: title.Rating, + PosterId: title.PosterID, + } + result = append(result, t) + } + + return oapi.GetTitle200JSONResponse(result), nil +} From d04248ab7a8b82c7852a082a2bfec92c2be0d20f Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 01:02:30 +0300 Subject: [PATCH 10/97] feat --- api/openapi.yaml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 43e380d..f1c40f9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -10,7 +10,6 @@ paths: summary: Get titles parameters: - in: query -<<<<<<< Updated upstream name: word schema: type: string @@ -33,12 +32,6 @@ paths: schema: $ref: '#/components/schemas/ReleaseSeason' - in: query -======= - name: query - schema: - type: string - - in: query ->>>>>>> Stashed changes name: limit schema: type: integer @@ -64,14 +57,10 @@ paths: $ref: '#/components/schemas/Title' '204': description: No titles found -<<<<<<< Updated upstream '400': description: Request params are not correct '500': description: Unknown server error -======= ->>>>>>> Stashed changes - # /title/{title_id}: # get: # summary: Get title description From ae01eec0fdf2d3ff4f14e8df756983d4a6d97c76 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 02:50:29 +0300 Subject: [PATCH 11/97] fix!: some types were changed --- api/api.gen.go | 10 ++++++---- api/openapi.yaml | 20 +++++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index d17b591..a235db8 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -36,9 +36,9 @@ type ReleaseSeason string // Title defines model for Title. type Title struct { - EpisodesAired *int32 `json:"episodes_aired,omitempty"` - EpisodesAll *int32 `json:"episodes_all,omitempty"` - EpisodesLen *[]float64 `json:"episodes_len,omitempty"` + EpisodesAired *int32 `json:"episodes_aired,omitempty"` + EpisodesAll *int32 `json:"episodes_all,omitempty"` + EpisodesLen *map[string]float64 `json:"episodes_len,omitempty"` // Id Unique title ID (primary key) Id *int64 `json:"id,omitempty"` @@ -50,7 +50,9 @@ type Title struct { ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` ReleaseYear *int32 `json:"release_year,omitempty"` StudioId *int64 `json:"studio_id,omitempty"` - TitleNames *[]string `json:"title_names,omitempty"` + + // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names + TitleNames *map[string][]string `json:"title_names,omitempty"` // TitleStatus Title status TitleStatus *TitleStatus `json:"title_status,omitempty"` diff --git a/api/openapi.yaml b/api/openapi.yaml index f1c40f9..4187ebb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -592,9 +592,19 @@ components: description: Unique title ID (primary key) example: 1 title_names: - type: array - items: - type: string + type: object + description: "Localized titles. Key = language (ISO 639-1), value = list of names" + additionalProperties: + type: array + items: + type: string + example: "Attack on Titan" + minItems: 1 + example: ["Attack on Titan", "AoT"] + example: + en: ["Attack on Titan", "AoT"] + ru: ["Атака титанов", "Титаны"] + ja: ["進撃の巨人"] studio_id: type: integer format: int64 @@ -621,8 +631,8 @@ components: type: integer format: int32 episodes_len: - type: array - items: + type: object + additionalProperties: type: number format: double additionalProperties: true From e8783a0e9df1eaeb25c52644504deaf750b45cdf Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 02:51:13 +0300 Subject: [PATCH 12/97] feat: get title func written --- modules/backend/handlers/titles.go | 47 +++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 7bfcf58..85f9f45 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" @@ -40,9 +41,10 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { if s == nil { return nil, nil } + //TODO var t sqlc.ReleaseSeasonT - if *s == "winter" { - t = "winter" + if *s == oapi.Winter { + t = sqlc.ReleaseSeasonTWinter } else if *s == "spring" { t = "spring" } else if *s == "summer" { @@ -55,6 +57,23 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } +// unmarshall jsonb to map[string][]string +func jsonb2map4names(b []byte) (*map[string][]string, error) { + var t map[string][]string + if err := json.Unmarshal(b, &t); err != nil { + return nil, fmt.Errorf("invalid title_names JSON for title: %w", err) + } + return &t, nil +} + +func jsonb2map4len(b []byte) (*map[string]float64, error) { + var t map[string]float64 + if err := json.Unmarshal(b, &t); err != nil { + return nil, fmt.Errorf("invalid episodes_len JSON for title: %w", err) + } + return &t, nil +} + func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { var result []oapi.Title @@ -85,9 +104,29 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } for _, title := range titles { + title_names, err := jsonb2map4names(title.TitleNames) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle500Response{}, err + } + episodes_lens, err := jsonb2map4len(title.EpisodesLen) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle500Response{}, err + } t := oapi.Title{ - Rating: title.Rating, - PosterId: title.PosterID, + Id: &title.ID, + PosterId: title.PosterID, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), + ReleaseYear: title.ReleaseYear, + StudioId: &title.StudioID, + TitleNames: title_names, + TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + EpisodesLen: episodes_lens, } result = append(result, t) } From 5cc67579000c0f3369de0ce83aa1faeb8900f5b6 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 02:51:52 +0300 Subject: [PATCH 13/97] feat: minor changes to db and new query --- sql/migrations/000001_init.up.sql | 4 +- sql/models.go | 37 ++++++---- sql/queries.sql.go | 114 ++++++++++++++++++++++++++++++ sql/sqlc.yaml | 12 +++- 4 files changed, 152 insertions(+), 15 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index a5e89b8..c325dc8 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -44,6 +44,7 @@ CREATE TABLE studios ( CREATE TABLE titles ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, studio_id bigint NOT NULL REFERENCES studios (id), poster_id bigint REFERENCES images (id), @@ -55,6 +56,7 @@ CREATE TABLE titles ( season int CHECK (season >= 0), episodes_aired int CHECK (episodes_aired >= 0), episodes_all int CHECK (episodes_all >= 0), + -- example { "1": "50.50", "2": "23.23"} episodes_len jsonb, CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL @@ -86,7 +88,7 @@ CREATE TABLE signals ( ); CREATE TABLE external_ids ( - user_id NOT NULL REFERENCES users (id), + user_id bigint NOT NULL REFERENCES users (id), service_id bigint REFERENCES external_services (id), external_id text NOT NULL ); diff --git a/sql/models.go b/sql/models.go index 928d5ac..6583b71 100644 --- a/sql/models.go +++ b/sql/models.go @@ -185,6 +185,17 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { return string(ns.UsertitleStatusT), nil } +type ExternalID struct { + UserID int64 `json:"user_id"` + ServiceID *int64 `json:"service_id"` + ExternalID string `json:"external_id"` +} + +type ExternalService struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + type Image struct { ID int64 `json:"id"` StorageType StorageTypeT `json:"storage_type"` @@ -218,19 +229,19 @@ type Tag struct { } type Title struct { - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason NullReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` } type TitleTag struct { diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 8f92c2a..2ef4178 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -71,3 +71,117 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er ) return i, err } + +const searchTitles = `-- name: SearchTitles :many + + + + +SELECT + id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) + AND ($3::float IS NULL OR rating >= $3::float) + AND ($4::int IS NULL OR release_year = $4::int) + AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) + +LIMIT COALESCE($7::int, 100) -- 100 is default limit +OFFSET $6::int +` + +type SearchTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Offset *int32 `json:"offset"` + Limit *int32 `json:"limit"` +} + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: UpdateUser :one +// UPDATE users +// SET +// +// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), +// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), +// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), +// passhash = COALESCE(sqlc.narg('passhash'), passhash) +// +// WHERE user_id = sqlc.arg('user_id') +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: DeleteUser :exec +// DELETE FROM users +// WHERE user_id = $1; +func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { + rows, err := q.db.Query(ctx, searchTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Title + for rows.Next() { + var i Title + if err := rows.Scan( + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f44761e..f74d2ad 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -24,4 +24,14 @@ sql: nullable: false go_type: import: "time" - type: "Time" \ No newline at end of file + type: "Time" + - db_type: "title_status_t" + nullable: true + go_type: + pointer: true + type: "TitleStatusT" + - db_type: "release_season_t" + nullable: true + go_type: + pointer: true + type: "ReleaseSeasonT" \ No newline at end of file From f49fad2307e17b4b704c833c8d14dd958adc2a85 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 03:01:38 +0300 Subject: [PATCH 14/97] fix: tmp commeneted method --- api/api.gen.go | 77 ------------------------------- api/openapi.yaml | 44 +++++++++--------- modules/backend/handlers/users.go | 2 +- 3 files changed, 23 insertions(+), 100 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index a235db8..417554a 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -103,9 +103,6 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. -type PostUsersJSONRequestBody = User - // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -344,9 +341,6 @@ type ServerInterface interface { // Get titles // (GET /title) GetTitle(c *gin.Context, params GetTitleParams) - // Add new user - // (POST /users) - PostUsers(c *gin.Context) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -443,19 +437,6 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, params) } -// PostUsers operation middleware -func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsers(c) -} - // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -519,7 +500,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.POST(options.BaseURL+"/users", wrapper.PostUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } @@ -564,27 +544,6 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } -type PostUsersRequestObject struct { - Body *PostUsersJSONRequestBody -} - -type PostUsersResponseObject interface { - VisitPostUsersResponse(w http.ResponseWriter) error -} - -type PostUsers200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -632,9 +591,6 @@ type StrictServerInterface interface { // Get titles // (GET /title) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) - // Add new user - // (POST /users) - PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -679,39 +635,6 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } -// PostUsers operation middleware -func (sh *strictHandler) PostUsers(ctx *gin.Context) { - var request PostUsersRequestObject - - var body PostUsersJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersResponseObject); ok { - if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 4187ebb..bfd9aae 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -243,28 +243,28 @@ paths: # items: # $ref: '#/components/schemas/User' - post: - summary: Add new user - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Add result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - user_json: - $ref: '#/components/schemas/User' + # post: + # summary: Add new user + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Add result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + # user_json: + # $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3da61de..9e59261 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -25,7 +25,7 @@ import ( func mapUser(u sqlc.GetUserByIDRow) oapi.User { return oapi.User{ AvatarId: u.AvatarID, - CreationDate: u.CreationDate, + CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, Mail: (*types.Email)(u.Mail), From 2edf96571b0edcbaf4d8e03a456b41e6a79163d0 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 18:20:27 +0300 Subject: [PATCH 15/97] feat: field studio_name added to title in openapi --- api/api.gen.go | 93 ++++++------------------------- api/openapi.yaml | 46 +++++++-------- modules/backend/handlers/users.go | 2 +- 3 files changed, 41 insertions(+), 100 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index a235db8..9adc19d 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -50,6 +50,7 @@ type Title struct { ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` ReleaseYear *int32 `json:"release_year,omitempty"` StudioId *int64 `json:"studio_id,omitempty"` + StudioName *string `json:"studio_name,omitempty"` // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names TitleNames *map[string][]string `json:"title_names,omitempty"` @@ -103,9 +104,6 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. -type PostUsersJSONRequestBody = User - // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -211,6 +209,14 @@ func (a *Title) UnmarshalJSON(b []byte) error { delete(object, "studio_id") } + if raw, found := object["studio_name"]; found { + err = json.Unmarshal(raw, &a.StudioName) + if err != nil { + return fmt.Errorf("error reading 'studio_name': %w", err) + } + delete(object, "studio_name") + } + if raw, found := object["title_names"]; found { err = json.Unmarshal(raw, &a.TitleNames) if err != nil { @@ -316,6 +322,13 @@ func (a Title) MarshalJSON() ([]byte, error) { } } + if a.StudioName != nil { + object["studio_name"], err = json.Marshal(a.StudioName) + if err != nil { + return nil, fmt.Errorf("error marshaling 'studio_name': %w", err) + } + } + if a.TitleNames != nil { object["title_names"], err = json.Marshal(a.TitleNames) if err != nil { @@ -344,9 +357,6 @@ type ServerInterface interface { // Get titles // (GET /title) GetTitle(c *gin.Context, params GetTitleParams) - // Add new user - // (POST /users) - PostUsers(c *gin.Context) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -443,19 +453,6 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, params) } -// PostUsers operation middleware -func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsers(c) -} - // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -519,7 +516,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.POST(options.BaseURL+"/users", wrapper.PostUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } @@ -564,27 +560,6 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } -type PostUsersRequestObject struct { - Body *PostUsersJSONRequestBody -} - -type PostUsersResponseObject interface { - VisitPostUsersResponse(w http.ResponseWriter) error -} - -type PostUsers200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -632,9 +607,6 @@ type StrictServerInterface interface { // Get titles // (GET /title) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) - // Add new user - // (POST /users) - PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -679,39 +651,6 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } -// PostUsers operation middleware -func (sh *strictHandler) PostUsers(ctx *gin.Context) { - var request PostUsersRequestObject - - var body PostUsersJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersResponseObject); ok { - if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 4187ebb..c899595 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -243,28 +243,28 @@ paths: # items: # $ref: '#/components/schemas/User' - post: - summary: Add new user - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Add result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - user_json: - $ref: '#/components/schemas/User' + # post: + # summary: Add new user + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Add result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + # user_json: + # $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: @@ -608,6 +608,8 @@ components: studio_id: type: integer format: int64 + studio_name: + type: string poster_id: type: integer format: int64 diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3da61de..9e59261 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -25,7 +25,7 @@ import ( func mapUser(u sqlc.GetUserByIDRow) oapi.User { return oapi.User{ AvatarId: u.AvatarID, - CreationDate: u.CreationDate, + CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, Mail: (*types.Email)(u.Mail), From 3cca6ee16821dda7be772056f0ba3f62beab30c3 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 18:56:46 +0300 Subject: [PATCH 16/97] feat: statements for studios table added --- modules/backend/queries.sql | 14 ++++++++++++ sql/queries.sql.go | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b90ec6a..29c941e 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -13,6 +13,20 @@ SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date FROM users WHERE id = $1; + +-- name: GetStudioByID :one +SELECT * +FROM studios +WHERE id = sqlc.arg('studio_id')::int; + +-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + sqlc.arg('studio_name')::text, + sqlc.narg('illust_id')::bigint, + sqlc.narg('studio_desc')::text) +RETURNING id, studio_name, illust_id, studio_desc; + -- -- name: ListUsers :many -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -- FROM users diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 2ef4178..b3ee9f4 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -41,6 +41,24 @@ func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { return i, err } +const getStudioByID = `-- name: GetStudioByID :one +SELECT id, studio_name, illust_id, studio_desc +FROM studios +WHERE id = $1::int +` + +func (q *Queries) GetStudioByID(ctx context.Context, studioID int32) (Studio, error) { + row := q.db.QueryRow(ctx, getStudioByID, studioID) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + const getUserByID = `-- name: GetUserByID :one SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date FROM users @@ -72,6 +90,33 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const insertStudio = `-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + $1::text, + $2::bigint, + $3::text) +RETURNING id, studio_name, illust_id, studio_desc +` + +type InsertStudioParams struct { + StudioName string `json:"studio_name"` + IllustID *int64 `json:"illust_id"` + StudioDesc *string `json:"studio_desc"` +} + +func (q *Queries) InsertStudio(ctx context.Context, arg InsertStudioParams) (Studio, error) { + row := q.db.QueryRow(ctx, insertStudio, arg.StudioName, arg.IllustID, arg.StudioDesc) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + const searchTitles = `-- name: SearchTitles :many From e6dc27fd55abe64c96ae51e746c2c31625d9982c Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 19:19:39 +0300 Subject: [PATCH 17/97] feat: statements for tags added --- modules/backend/queries.sql | 20 ++++++++++++ sql/queries.sql.go | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 29c941e..a4c0bb9 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -27,6 +27,26 @@ VALUES ( sqlc.narg('studio_desc')::text) RETURNING id, studio_name, illust_id, studio_desc; +-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = sqlc.arg('title_id')::bigint; + +-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + sqlc.arg('title_id')::bigint, + sqlc.arg('tag_id')::bigint) +RETURNING title_id, tag_id; + +-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + sqlc.arg('tag_names')::jsonb) +RETURNING id, tag_names; + -- -- name: ListUsers :many -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -- FROM users diff --git a/sql/queries.sql.go b/sql/queries.sql.go index b3ee9f4..a73889c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -59,6 +59,34 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int32) (Studio, er return i, err } +const getTitleTags = `-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = $1::bigint +` + +func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, error) { + rows, err := q.db.Query(ctx, getTitleTags, titleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var tag_names []byte + if err := rows.Scan(&tag_names); err != nil { + return nil, err + } + items = append(items, tag_names) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUserByID = `-- name: GetUserByID :one SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date FROM users @@ -117,6 +145,40 @@ func (q *Queries) InsertStudio(ctx context.Context, arg InsertStudioParams) (Stu return i, err } +const insertTag = `-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + $1::jsonb) +RETURNING id, tag_names +` + +func (q *Queries) InsertTag(ctx context.Context, tagNames []byte) (Tag, error) { + row := q.db.QueryRow(ctx, insertTag, tagNames) + var i Tag + err := row.Scan(&i.ID, &i.TagNames) + return i, err +} + +const insertTitleTags = `-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + $1::bigint, + $2::bigint) +RETURNING title_id, tag_id +` + +type InsertTitleTagsParams struct { + TitleID int64 `json:"title_id"` + TagID int64 `json:"tag_id"` +} + +func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams) (TitleTag, error) { + row := q.db.QueryRow(ctx, insertTitleTags, arg.TitleID, arg.TagID) + var i TitleTag + err := row.Scan(&i.TitleID, &i.TagID) + return i, err +} + const searchTitles = `-- name: SearchTitles :many From 4949a3c25faeb933e6c92ae9938001adf7fefab8 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 15 Nov 2025 23:00:40 +0300 Subject: [PATCH 18/97] refactor: new types --- api/api.gen.go | 4 ++-- api/openapi.yaml | 2 ++ modules/backend/handlers/titles.go | 27 +++++++++++++++++---------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 9adc19d..ebb4c01 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -94,8 +94,8 @@ type GetTitleParams struct { Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } diff --git a/api/openapi.yaml b/api/openapi.yaml index c899595..200cc47 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -35,11 +35,13 @@ paths: name: limit schema: type: integer + format: int32 default: 10 - in: query name: offset schema: type: integer + format: int32 default: 0 - in: query name: fields diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 85f9f45..99217ca 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -57,21 +57,25 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } +type TileNames *map[string][]string + // unmarshall jsonb to map[string][]string -func jsonb2map4names(b []byte) (*map[string][]string, error) { - var t map[string][]string - if err := json.Unmarshal(b, &t); err != nil { +func jsonb2TitleNames(b []byte) (TileNames, error) { + var t TileNames + if err := json.Unmarshal(b, t); err != nil { return nil, fmt.Errorf("invalid title_names JSON for title: %w", err) } - return &t, nil + return t, nil } -func jsonb2map4len(b []byte) (*map[string]float64, error) { - var t map[string]float64 - if err := json.Unmarshal(b, &t); err != nil { +type EpisodeLens *map[string]float64 + +func jsonb2EpisodeLens(b []byte) (EpisodeLens, error) { + var t EpisodeLens + if err := json.Unmarshal(b, t); err != nil { return nil, fmt.Errorf("invalid episodes_len JSON for title: %w", err) } - return &t, nil + return t, nil } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { @@ -95,6 +99,8 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, + Offset: request.Params.Offset, + Limit: request.Params.Limit, }) if err != nil { return oapi.GetTitle500Response{}, nil @@ -104,12 +110,12 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } for _, title := range titles { - title_names, err := jsonb2map4names(title.TitleNames) + title_names, err := jsonb2TitleNames(title.TitleNames) if err != nil { log.Errorf("%v", err) return oapi.GetTitle500Response{}, err } - episodes_lens, err := jsonb2map4len(title.EpisodesLen) + episodes_lens, err := jsonb2EpisodeLens(title.EpisodesLen) if err != nil { log.Errorf("%v", err) return oapi.GetTitle500Response{}, err @@ -122,6 +128,7 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), ReleaseYear: title.ReleaseYear, StudioId: &title.StudioID, + // StudioName: , TitleNames: title_names, TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), EpisodesAired: title.EpisodesAired, From e18f4a44c3acf4b0fc36889690e853dd7b416c0e Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 16 Nov 2025 01:47:11 +0300 Subject: [PATCH 19/97] feat: more fields for titles and refactored schemas --- api/api.gen.go | 71 ++++++++++++++++++++++++++++-------------------- api/openapi.yaml | 71 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index ebb4c01..c3ef4da 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -34,6 +34,21 @@ const ( // ReleaseSeason Title release season type ReleaseSeason string +// Studio defines model for Studio. +type Studio struct { + Description *string `json:"description,omitempty"` + Id *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + PosterId *int64 `json:"poster_id,omitempty"` + PosterPath *string `json:"poster_path,omitempty"` +} + +// Tag A localized tag: keys are language codes (ISO 639-1), values are tag names +type Tag map[string]string + +// Tags Array of localized tags +type Tags = []Tag + // Title defines model for Title. type Title struct { EpisodesAired *int32 `json:"episodes_aired,omitempty"` @@ -41,7 +56,7 @@ type Title struct { EpisodesLen *map[string]float64 `json:"episodes_len,omitempty"` // Id Unique title ID (primary key) - Id *int64 `json:"id,omitempty"` + Id int64 `json:"id"` PosterId *int64 `json:"poster_id,omitempty"` Rating *float64 `json:"rating,omitempty"` RatingCount *int32 `json:"rating_count,omitempty"` @@ -49,11 +64,13 @@ type Title struct { // ReleaseSeason Title release season ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` ReleaseYear *int32 `json:"release_year,omitempty"` - StudioId *int64 `json:"studio_id,omitempty"` - StudioName *string `json:"studio_name,omitempty"` + Studio *Studio `json:"studio,omitempty"` + + // Tags Array of localized tags + Tags Tags `json:"tags"` // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names - TitleNames *map[string][]string `json:"title_names,omitempty"` + TitleNames map[string][]string `json:"title_names"` // TitleStatus Title status TitleStatus *TitleStatus `json:"title_status,omitempty"` @@ -201,20 +218,20 @@ func (a *Title) UnmarshalJSON(b []byte) error { delete(object, "release_year") } - if raw, found := object["studio_id"]; found { - err = json.Unmarshal(raw, &a.StudioId) + if raw, found := object["studio"]; found { + err = json.Unmarshal(raw, &a.Studio) if err != nil { - return fmt.Errorf("error reading 'studio_id': %w", err) + return fmt.Errorf("error reading 'studio': %w", err) } - delete(object, "studio_id") + delete(object, "studio") } - if raw, found := object["studio_name"]; found { - err = json.Unmarshal(raw, &a.StudioName) + if raw, found := object["tags"]; found { + err = json.Unmarshal(raw, &a.Tags) if err != nil { - return fmt.Errorf("error reading 'studio_name': %w", err) + return fmt.Errorf("error reading 'tags': %w", err) } - delete(object, "studio_name") + delete(object, "tags") } if raw, found := object["title_names"]; found { @@ -273,11 +290,9 @@ func (a Title) MarshalJSON() ([]byte, error) { } } - if a.Id != nil { - object["id"], err = json.Marshal(a.Id) - if err != nil { - return nil, fmt.Errorf("error marshaling 'id': %w", err) - } + object["id"], err = json.Marshal(a.Id) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) } if a.PosterId != nil { @@ -315,25 +330,21 @@ func (a Title) MarshalJSON() ([]byte, error) { } } - if a.StudioId != nil { - object["studio_id"], err = json.Marshal(a.StudioId) + if a.Studio != nil { + object["studio"], err = json.Marshal(a.Studio) if err != nil { - return nil, fmt.Errorf("error marshaling 'studio_id': %w", err) + return nil, fmt.Errorf("error marshaling 'studio': %w", err) } } - if a.StudioName != nil { - object["studio_name"], err = json.Marshal(a.StudioName) - if err != nil { - return nil, fmt.Errorf("error marshaling 'studio_name': %w", err) - } + object["tags"], err = json.Marshal(a.Tags) + if err != nil { + return nil, fmt.Errorf("error marshaling 'tags': %w", err) } - if a.TitleNames != nil { - object["title_names"], err = json.Marshal(a.TitleNames) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_names': %w", err) - } + object["title_names"], err = json.Marshal(a.TitleNames) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_names': %w", err) } if a.TitleStatus != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index 200cc47..0356898 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,8 +2,10 @@ openapi: 3.1.1 info: title: Titles, Users, Reviews, Tags, and Media API version: 1.0.0 + servers: - url: /api/v1 + paths: /title: get: @@ -63,6 +65,7 @@ paths: description: Request params are not correct '500': description: Unknown server error + # /title/{title_id}: # get: # summary: Get title description @@ -569,6 +572,7 @@ components: - finished - ongoing - planned + ReleaseSeason: type: string description: Title release season @@ -577,6 +581,7 @@ components: - spring - summer - fall + UserTitleStatus: type: string description: User's title status @@ -585,8 +590,57 @@ components: - planned - dropped - in-progress + + Review: + type: object + additionalProperties: true + + Tag: + type: object + description: "A localized tag: keys are language codes (ISO 639-1), values are tag names" + additionalProperties: + type: string + example: + en: "Shojo" + ru: "Сёдзё" + ja: "少女" + + Tags: + type: array + description: "Array of localized tags" + items: + $ref: '#/components/schemas/Tag' + example: + - en: "Shojo" + ru: "Сёдзё" + ja: "少女" + - en: "Shounen" + ru: "Сёнен" + ja: "少年" + + Studio: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + poster_id: + type: integer + format: int64 + poster_path: + type: string + description: + type: string + + Title: type: object + required: + - id + - title_names + - tags properties: id: type: integer @@ -607,11 +661,10 @@ components: en: ["Attack on Titan", "AoT"] ru: ["Атака титанов", "Титаны"] ja: ["進撃の巨人"] - studio_id: - type: integer - format: int64 - studio_name: - type: string + studio: + $ref: '#/components/schemas/Studio' + tags: + $ref: '#/components/schemas/Tags' poster_id: type: integer format: int64 @@ -640,6 +693,7 @@ components: type: number format: double additionalProperties: true + User: type: object properties: @@ -683,12 +737,7 @@ components: - user_id - nickname # - creation_date + UserTitle: type: object additionalProperties: true - Review: - type: object - additionalProperties: true - Tag: - type: object - additionalProperties: true From d1180a426ff4f043baaaec58afd894b4c2f5ffb4 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 16 Nov 2025 02:14:38 +0300 Subject: [PATCH 20/97] feat: new schema for images --- api/api.gen.go | 23 +++++++++++++++-------- api/openapi.yaml | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index c3ef4da..b3c51f6 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -31,6 +31,13 @@ const ( Planned TitleStatus = "planned" ) +// Image defines model for Image. +type Image struct { + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + StorageType *string `json:"storage_type,omitempty"` +} + // ReleaseSeason Title release season type ReleaseSeason string @@ -57,7 +64,7 @@ type Title struct { // Id Unique title ID (primary key) Id int64 `json:"id"` - PosterId *int64 `json:"poster_id,omitempty"` + Poster *Image `json:"poster,omitempty"` Rating *float64 `json:"rating,omitempty"` RatingCount *int32 `json:"rating_count,omitempty"` @@ -178,12 +185,12 @@ func (a *Title) UnmarshalJSON(b []byte) error { delete(object, "id") } - if raw, found := object["poster_id"]; found { - err = json.Unmarshal(raw, &a.PosterId) + if raw, found := object["poster"]; found { + err = json.Unmarshal(raw, &a.Poster) if err != nil { - return fmt.Errorf("error reading 'poster_id': %w", err) + return fmt.Errorf("error reading 'poster': %w", err) } - delete(object, "poster_id") + delete(object, "poster") } if raw, found := object["rating"]; found { @@ -295,10 +302,10 @@ func (a Title) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("error marshaling 'id': %w", err) } - if a.PosterId != nil { - object["poster_id"], err = json.Marshal(a.PosterId) + if a.Poster != nil { + object["poster"], err = json.Marshal(a.Poster) if err != nil { - return nil, fmt.Errorf("error marshaling 'poster_id': %w", err) + return nil, fmt.Errorf("error marshaling 'poster': %w", err) } } diff --git a/api/openapi.yaml b/api/openapi.yaml index 0356898..d523f06 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -565,6 +565,17 @@ paths: components: schemas: + Image: + type: object + properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string + TitleStatus: type: string description: Title status @@ -665,9 +676,8 @@ components: $ref: '#/components/schemas/Studio' tags: $ref: '#/components/schemas/Tags' - poster_id: - type: integer - format: int64 + poster: + $ref: '#/components/schemas/Image' title_status: $ref: '#/components/schemas/TitleStatus' rating: From 13a283ae8da0e138c1723d53517d1c9c6f01cc32 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 16 Nov 2025 02:38:36 +0300 Subject: [PATCH 21/97] feat: GetTitles now returns all the field needed --- api/api.gen.go | 3 +- api/openapi.yaml | 7 +- modules/backend/handlers/titles.go | 147 +++++++++++++++++++++-------- modules/backend/queries.sql | 4 +- sql/migrations/000001_init.up.sql | 1 + sql/queries.sql.go | 8 +- 6 files changed, 119 insertions(+), 51 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index b3c51f6..0057c22 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -46,8 +46,7 @@ type Studio struct { Description *string `json:"description,omitempty"` Id *int64 `json:"id,omitempty"` Name *string `json:"name,omitempty"` - PosterId *int64 `json:"poster_id,omitempty"` - PosterPath *string `json:"poster_path,omitempty"` + Poster *Image `json:"poster,omitempty"` } // Tag A localized tag: keys are language codes (ISO 639-1), values are tag names diff --git a/api/openapi.yaml b/api/openapi.yaml index d523f06..4628c6d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -637,11 +637,8 @@ components: format: int64 name: type: string - poster_id: - type: integer - format: int64 - poster_path: - type: string + poster: + $ref: '#/components/schemas/Image' description: type: string diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 99217ca..182f450 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -25,13 +25,14 @@ func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { return nil, nil } var t sqlc.TitleStatusT - if *s == "finished" { - t = "finished" - } else if *s == "ongoing" { - t = "ongoing" - } else if *s == "planned" { - t = "planned" - } else { + switch *s { + case oapi.Finished: + t = sqlc.TitleStatusTFinished + case oapi.Ongoing: + t = sqlc.TitleStatusTOngoing + case oapi.Planned: + t = sqlc.TitleStatusTPlanned + default: return nil, fmt.Errorf("unexpected tittle status: %s", *s) } return &t, nil @@ -41,41 +42,86 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { if s == nil { return nil, nil } - //TODO var t sqlc.ReleaseSeasonT - if *s == oapi.Winter { + switch *s { + case oapi.Winter: t = sqlc.ReleaseSeasonTWinter - } else if *s == "spring" { - t = "spring" - } else if *s == "summer" { - t = "summer" - } else if *s == "fall" { - t = "fall" - } else { + case oapi.Spring: + t = sqlc.ReleaseSeasonTSpring + case oapi.Summer: + t = sqlc.ReleaseSeasonTSummer + case oapi.Fall: + t = sqlc.ReleaseSeasonTFall + default: return nil, fmt.Errorf("unexpected release season: %s", *s) } return &t, nil } -type TileNames *map[string][]string +type TitleNames *map[string][]string +type EpisodeLens *map[string]float64 +type TagNames []map[string]string -// unmarshall jsonb to map[string][]string -func jsonb2TitleNames(b []byte) (TileNames, error) { - var t TileNames - if err := json.Unmarshal(b, t); err != nil { - return nil, fmt.Errorf("invalid title_names JSON for title: %w", err) +func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { + sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) + if err != nil { + log.Errorf("%v", err) + return nil, err } - return t, nil + + var oapi_tag_names oapi.Tags + for _, title_tag := range sqlc_title_tags { + var oapi_tag_name map[string]string + err = json.Unmarshal(title_tag, &oapi_tag_name) + if err != nil { + log.Errorf("invalid JSON for %s: %v", "TagNames", err) + return nil, err + } + oapi_tag_names = append(oapi_tag_names, oapi_tag_name) + } + + return oapi_tag_names, nil } -type EpisodeLens *map[string]float64 +func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { -func jsonb2EpisodeLens(b []byte) (EpisodeLens, error) { - var t EpisodeLens - if err := json.Unmarshal(b, t); err != nil { - return nil, fmt.Errorf("invalid episodes_len JSON for title: %w", err) + var oapi_image oapi.Image + + sqlc_image, err := s.db.GetImageByID(ctx, id) + if err != nil { + log.Errorf("%v", err) + return oapi_image, err } - return t, nil + //can cast and dont use brain cause all this fiels required + oapi_image.Id = &sqlc_image.ID + oapi_image.ImagePath = &sqlc_image.ImagePath + oapi_image.StorageType = (*string)(&sqlc_image.StorageType) + + return oapi_image, nil +} + +func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { + + var oapi_studio oapi.Studio + + sqlc_studio, err := s.db.GetStudioByID(ctx, id) + if err != nil { + log.Errorf("%v", err) + return oapi_studio, err + } + + oapi_studio.Id = &sqlc_studio.ID + oapi_studio.Name = sqlc_studio.StudioName + oapi_studio.Description = sqlc_studio.StudioDesc + + oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) + if err != nil { + log.Errorf("%v", err) + return oapi_studio, err + } + oapi_studio.Poster = &oapi_illust + + return oapi_studio, nil } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { @@ -103,6 +149,7 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject Limit: request.Params.Limit, }) if err != nil { + log.Errorf("%v", err) return oapi.GetTitle500Response{}, nil } if len(titles) == 0 { @@ -110,26 +157,50 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } for _, title := range titles { - title_names, err := jsonb2TitleNames(title.TitleNames) + + var title_names TitleNames + err := json.Unmarshal(title.TitleNames, &title_names) if err != nil { - log.Errorf("%v", err) + log.Errorf("invalid JSON for %s: %v", "TitleNames", err) return oapi.GetTitle500Response{}, err } - episodes_lens, err := jsonb2EpisodeLens(title.EpisodesLen) + + var episodes_lens EpisodeLens + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { - log.Errorf("%v", err) + log.Errorf("invalid JSON for %s: %v", "EpisodeLens", err) return oapi.GetTitle500Response{}, err } + + oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) + if err != nil { + log.Errorf("error while getting tags %v", err) + return oapi.GetTitle500Response{}, err + } + + oapi_image, err := s.GetImage(ctx, *title.PosterID) + if err != nil { + log.Errorf("error while getting image %v", err) + return oapi.GetTitle500Response{}, err + } + + oapi_studio, err := s.GetStudio(ctx, title.StudioID) + if err != nil { + log.Errorf("error while getting studio %v", err) + return oapi.GetTitle500Response{}, err + } + t := oapi.Title{ - Id: &title.ID, - PosterId: title.PosterID, + + Id: title.ID, + Poster: &oapi_image, Rating: title.Rating, RatingCount: title.RatingCount, ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), ReleaseYear: title.ReleaseYear, - StudioId: &title.StudioID, - // StudioName: , - TitleNames: title_names, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: *title_names, TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), EpisodesAired: title.EpisodesAired, EpisodesAll: title.EpisodesAll, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index a4c0bb9..7717f9f 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,7 +1,7 @@ -- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = $1; +WHERE id = sqlc.arg('illust_id'); -- name: CreateImage :one INSERT INTO images (storage_type, image_path) @@ -17,7 +17,7 @@ WHERE id = $1; -- name: GetStudioByID :one SELECT * FROM studios -WHERE id = sqlc.arg('studio_id')::int; +WHERE id = sqlc.arg('studio_id')::bigint; -- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index c325dc8..669143a 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -14,6 +14,7 @@ CREATE TABLE providers ( CREATE TABLE tags ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example: { "ru": "Сёдзё", "en": "Shojo", "jp": "少女"} tag_names jsonb NOT NULL ); diff --git a/sql/queries.sql.go b/sql/queries.sql.go index a73889c..865ec73 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -34,8 +34,8 @@ FROM images WHERE id = $1 ` -func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { - row := q.db.QueryRow(ctx, getImageByID, id) +func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, illustID) var i Image err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) return i, err @@ -44,10 +44,10 @@ func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios -WHERE id = $1::int +WHERE id = $1::bigint ` -func (q *Queries) GetStudioByID(ctx context.Context, studioID int32) (Studio, error) { +func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, error) { row := q.db.QueryRow(ctx, getStudioByID, studioID) var i Studio err := row.Scan( From cefbbec1dc2fa2ad1bcc7dc711fe2ddb16f7eb56 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 16 Nov 2025 02:43:28 +0300 Subject: [PATCH 22/97] feat: wrote query to get one title --- modules/backend/queries.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7717f9f..f58d33c 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -72,6 +72,11 @@ RETURNING id, tag_names; -- DELETE FROM users -- WHERE user_id = $1; +--name: GetTitleByID :one +SELECT * +FROM titles +WHERE id = sqlc.arg("title_id")::bigint; + -- name: SearchTitles :many SELECT * From 47989ab10da73e351f9550dbae328698c38bc68e Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 16 Nov 2025 03:38:51 +0300 Subject: [PATCH 23/97] feat: /titles/{id} endpoint implemented --- api/api.gen.go | 117 +++++++++++++++++++++++ api/openapi.yaml | 51 +++++----- modules/backend/handlers/titles.go | 144 ++++++++++++++++------------- modules/backend/queries.sql | 4 +- sql/queries.sql.go | 81 ++++++++++------ 5 files changed, 283 insertions(+), 114 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 0057c22..3f4f5dd 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -122,6 +122,11 @@ type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// GetTitleTitleIdParams defines parameters for GetTitleTitleId. +type GetTitleTitleIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + // GetUsersUserIdParams defines parameters for GetUsersUserId. type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` @@ -374,6 +379,9 @@ type ServerInterface interface { // Get titles // (GET /title) GetTitle(c *gin.Context, params GetTitleParams) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(c *gin.Context, titleId int64, params GetTitleTitleIdParams) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -470,6 +478,41 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, params) } +// GetTitleTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleTitleIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitleTitleId(c, titleId, params) +} + // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -533,6 +576,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } @@ -577,6 +621,48 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } +type GetTitleTitleIdRequestObject struct { + TitleId int64 `json:"title_id"` + Params GetTitleTitleIdParams +} + +type GetTitleTitleIdResponseObject interface { + VisitGetTitleTitleIdResponse(w http.ResponseWriter) error +} + +type GetTitleTitleId200JSONResponse Title + +func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleId400Response struct { +} + +func (response GetTitleTitleId400Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetTitleTitleId404Response struct { +} + +func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetTitleTitleId500Response struct { +} + +func (response GetTitleTitleId500Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -624,6 +710,9 @@ type StrictServerInterface interface { // Get titles // (GET /title) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -668,6 +757,34 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } +// GetTitleTitleId operation middleware +func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId int64, params GetTitleTitleIdParams) { + var request GetTitleTitleIdRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitleTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 4628c6d..9ea20f4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -66,29 +66,34 @@ paths: '500': description: Unknown server error -# /title/{title_id}: -# get: -# summary: Get title description -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# - in: query -# name: fields -# schema: -# type: string -# default: all -# responses: -# '200': -# description: Title description -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Title' -# '404': -# description: Title not found + /title/{title_id}: + get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: '#/components/schemas/Title' + '404': + description: Title not found + '400': + description: Request params are not correct + '500': + description: Unknown server error # patch: # summary: Update title info diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 182f450..3bbaa10 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -58,15 +58,15 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } -type TitleNames *map[string][]string -type EpisodeLens *map[string]float64 -type TagNames []map[string]string +// type TitleNames map[string][]string +// type EpisodeLens map[string]float64 +// type TagNames []map[string]string func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { + sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) if err != nil { - log.Errorf("%v", err) - return nil, err + return nil, fmt.Errorf("query GetTitleTags: %v", err) } var oapi_tag_names oapi.Tags @@ -74,8 +74,7 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro var oapi_tag_name map[string]string err = json.Unmarshal(title_tag, &oapi_tag_name) if err != nil { - log.Errorf("invalid JSON for %s: %v", "TagNames", err) - return nil, err + return nil, fmt.Errorf("unmarshalling title_tag: %v", err) } oapi_tag_names = append(oapi_tag_names, oapi_tag_name) } @@ -89,8 +88,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { - log.Errorf("%v", err) - return oapi_image, err + return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } //can cast and dont use brain cause all this fiels required oapi_image.Id = &sqlc_image.ID @@ -106,8 +104,7 @@ func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { sqlc_studio, err := s.db.GetStudioByID(ctx, id) if err != nil { - log.Errorf("%v", err) - return oapi_studio, err + return oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) } oapi_studio.Id = &sqlc_studio.ID @@ -116,16 +113,82 @@ func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) if err != nil { - log.Errorf("%v", err) - return oapi_studio, err + return oapi_studio, fmt.Errorf("GetImage: %v", err) } oapi_studio.Poster = &oapi_illust return oapi_studio, nil } +func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { + var oapi_title oapi.Title + + var title_names map[string][]string + err := json.Unmarshal(title.TitleNames, &title_names) + if err != nil { + return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) + } + + var episodes_lens map[string]float64 + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } + + oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) + if err != nil { + return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) + } + + oapi_image, err := s.GetImage(ctx, *title.PosterID) + if err != nil { + return oapi_title, fmt.Errorf("GetImage: %v", err) + } + + oapi_studio, err := s.GetStudio(ctx, title.StudioID) + if err != nil { + return oapi_title, fmt.Errorf("GetStudio: %v", err) + } + + oapi_title = oapi.Title{ + + Id: title.ID, + Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), + ReleaseYear: title.ReleaseYear, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: title_names, + TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + EpisodesLen: &episodes_lens, + } + return oapi_title, nil +} + +func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleIdRequestObject) (oapi.GetTitleTitleIdResponseObject, error) { + var oapi_title oapi.Title + + sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitleTitleId500Response{}, nil + } + + oapi_title, err = s.mapTitle(ctx, sqlc_title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitleTitleId500Response{}, nil + } + + return oapi.GetTitleTitleId200JSONResponse(oapi_title), nil +} + func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { - var result []oapi.Title + var opai_titles []oapi.Title word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -158,56 +221,13 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject for _, title := range titles { - var title_names TitleNames - err := json.Unmarshal(title.TitleNames, &title_names) + t, err := s.mapTitle(ctx, title) if err != nil { - log.Errorf("invalid JSON for %s: %v", "TitleNames", err) - return oapi.GetTitle500Response{}, err + log.Errorf("%v", err) + return oapi.GetTitle500Response{}, nil } - - var episodes_lens EpisodeLens - err = json.Unmarshal(title.EpisodesLen, &episodes_lens) - if err != nil { - log.Errorf("invalid JSON for %s: %v", "EpisodeLens", err) - return oapi.GetTitle500Response{}, err - } - - oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) - if err != nil { - log.Errorf("error while getting tags %v", err) - return oapi.GetTitle500Response{}, err - } - - oapi_image, err := s.GetImage(ctx, *title.PosterID) - if err != nil { - log.Errorf("error while getting image %v", err) - return oapi.GetTitle500Response{}, err - } - - oapi_studio, err := s.GetStudio(ctx, title.StudioID) - if err != nil { - log.Errorf("error while getting studio %v", err) - return oapi.GetTitle500Response{}, err - } - - t := oapi.Title{ - - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: *title_names, - TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAll, - EpisodesLen: episodes_lens, - } - result = append(result, t) + opai_titles = append(opai_titles, t) } - return oapi.GetTitle200JSONResponse(result), nil + return oapi.GetTitle200JSONResponse(opai_titles), nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index f58d33c..0570604 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -72,10 +72,10 @@ RETURNING id, tag_names; -- DELETE FROM users -- WHERE user_id = $1; ---name: GetTitleByID :one +-- name: GetTitleByID :one SELECT * FROM titles -WHERE id = sqlc.arg("title_id")::bigint; +WHERE id = sqlc.arg('title_id')::bigint; -- name: SearchTitles :many SELECT diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 865ec73..8539d8d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -59,6 +59,60 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er return i, err } +const getTitleByID = `-- name: GetTitleByID :one + + + + +SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE id = $1::bigint +` + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: UpdateUser :one +// UPDATE users +// SET +// +// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), +// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), +// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), +// passhash = COALESCE(sqlc.narg('passhash'), passhash) +// +// WHERE user_id = sqlc.arg('user_id') +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: DeleteUser :exec +// DELETE FROM users +// WHERE user_id = $1; +func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error) { + row := q.db.QueryRow(ctx, getTitleByID, titleID) + var i Title + err := row.Scan( + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ) + return i, err +} + const getTitleTags = `-- name: GetTitleTags :many SELECT tag_names @@ -180,10 +234,6 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams } const searchTitles = `-- name: SearchTitles :many - - - - SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len FROM titles @@ -228,29 +278,6 @@ type SearchTitlesParams struct { Limit *int32 `json:"limit"` } -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: UpdateUser :one -// UPDATE users -// SET -// -// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), -// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), -// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), -// passhash = COALESCE(sqlc.narg('passhash'), passhash) -// -// WHERE user_id = sqlc.arg('user_id') -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: DeleteUser :exec -// DELETE FROM users -// WHERE user_id = $1; func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { rows, err := q.db.Query(ctx, searchTitles, arg.Word, From 29f0a612996039d3be25563873f5eab23298d03d Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 16 Nov 2025 04:15:33 +0300 Subject: [PATCH 24/97] feat: reviews table added --- sql/migrations/000001_init.up.sql | 16 ++++++++++++++++ sql/models.go | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 669143a..97f881d 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -24,6 +24,22 @@ CREATE TABLE images ( image_path text UNIQUE NOT NULL ); +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + illust_id bigint REFERENCES images (id), + user_id bigint REFERENCES users (id), + title_id bigint REFERENCES titles (id), + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, avatar_id bigint REFERENCES images (id), diff --git a/sql/models.go b/sql/models.go index 6583b71..a36c6fa 100644 --- a/sql/models.go +++ b/sql/models.go @@ -208,6 +208,21 @@ type Provider struct { Credentials []byte `json:"credentials"` } +type Review struct { + ID int64 `json:"id"` + Data string `json:"data"` + Rating *int32 `json:"rating"` + IllustID *int64 `json:"illust_id"` + UserID *int64 `json:"user_id"` + TitleID *int64 `json:"title_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ReviewImage struct { + ReviewID int64 `json:"review_id"` + ImageID int64 `json:"image_id"` +} + type Signal struct { ID int64 `json:"id"` TitleID *int64 `json:"title_id"` From b81cc86beb8f1bff094084d28b115f520efd2437 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 16 Nov 2025 04:52:11 +0300 Subject: [PATCH 25/97] fix: --- modules/backend/handlers/titles.go | 40 ++++++++++++++++++------------ 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 3bbaa10..e5fcf18 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -90,10 +90,12 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { if err != nil { return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } + //can cast and dont use brain cause all this fiels required oapi_image.Id = &sqlc_image.ID oapi_image.ImagePath = &sqlc_image.ImagePath - oapi_image.StorageType = (*string)(&sqlc_image.StorageType) + storageTypeStr := string(sqlc_image.StorageType) // или fmt.Sprint(...), если int + oapi_image.StorageType = &storageTypeStr return oapi_image, nil } @@ -150,22 +152,28 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err return oapi_title, fmt.Errorf("GetStudio: %v", err) } - oapi_title = oapi.Title{ - - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: title_names, - TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAll, - EpisodesLen: &episodes_lens, + if title.ReleaseSeason != nil { + rs := oapi.ReleaseSeason(*title.ReleaseSeason) + oapi_title.ReleaseSeason = &rs + } else { + oapi_title.ReleaseSeason = nil } + + ts := oapi.TitleStatus(title.TitleStatus) + oapi_title.TitleStatus = &ts + + oapi_title.Id = title.ID + oapi_title.Poster = &oapi_image + oapi_title.Rating = title.Rating + oapi_title.RatingCount = title.RatingCount + oapi_title.ReleaseYear = title.ReleaseYear + oapi_title.Studio = &oapi_studio + oapi_title.Tags = oapi_tag_names + oapi_title.TitleNames = title_names + oapi_title.EpisodesAired = title.EpisodesAired + oapi_title.EpisodesAll = title.EpisodesAll + oapi_title.EpisodesLen = &episodes_lens + return oapi_title, nil } From f888ac9b70ae3514baa643fbbaa07c70b2de5e99 Mon Sep 17 00:00:00 2001 From: nihonium Date: Mon, 17 Nov 2025 02:41:45 +0300 Subject: [PATCH 26/97] review: review notes for titles.go --- modules/backend/handlers/titles.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index e5fcf18..6e3967a 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -11,6 +11,7 @@ import ( ) func Word2Sqlc(s *string) *string { + // TODO: merge two ifs if s == nil { return nil } @@ -69,6 +70,7 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return nil, fmt.Errorf("query GetTitleTags: %v", err) } + // TODO: check if underlying slice initialized as nil var oapi_tag_names oapi.Tags for _, title_tag := range sqlc_title_tags { var oapi_tag_name map[string]string @@ -91,6 +93,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } + // TODO: clearer comment //can cast and dont use brain cause all this fiels required oapi_image.Id = &sqlc_image.ID oapi_image.ImagePath = &sqlc_image.ImagePath @@ -101,7 +104,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { } func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { - + // TODO: check all possibly nil fields var oapi_studio oapi.Studio sqlc_studio, err := s.db.GetStudioByID(ctx, id) @@ -123,6 +126,7 @@ func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { } func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { + // TODO: check all possibly nil fields var oapi_title oapi.Title var title_names map[string][]string From 35a4e173f08898ccad3daaee18532ab8063e124f Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Mon, 17 Nov 2025 03:55:42 +0300 Subject: [PATCH 27/97] feat: query GetReviewById added --- modules/backend/queries.sql | 10 ++++---- sql/queries.sql.go | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 0570604..a4ed1fe 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -135,10 +135,10 @@ OFFSET sqlc.narg('offset')::int; -- WHERE title_id = sqlc.arg('title_id') -- RETURNING *; --- -- name: GetReviewByID :one --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE review_id = $1; +-- name: GetReviewByID :one +SELECT * +FROM reviews +WHERE review_id = sqlc.arg('review_id')::bigint; -- -- name: CreateReview :one -- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) @@ -157,7 +157,7 @@ OFFSET sqlc.narg('offset')::int; -- DELETE FROM reviews -- WHERE review_id = $1; --- -- name: ListReviewsByTitle :many +-- name: ListReviewsByTitle :many -- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -- FROM reviews -- WHERE title_id = $1 diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 8539d8d..c5e6f8a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -41,6 +41,55 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro return i, err } +const getReviewByID = `-- name: GetReviewByID :one + + +SELECT id, data, rating, illust_id, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint +` + +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Review + err := row.Scan( + &i.ID, + &i.Data, + &i.Rating, + &i.IllustID, + &i.UserID, + &i.TitleID, + &i.CreatedAt, + ) + return i, err +} + const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios From df45a327e6902348a5d80674757e53e1e1eef6bd Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Mon, 17 Nov 2025 09:26:58 +0300 Subject: [PATCH 28/97] fix --- api/api.gen.go | 12 ++++- api/openapi.yaml | 5 ++ modules/backend/handlers/titles.go | 74 +++++++++++++++++------------- sql/migrations/000001_init.up.sql | 2 +- sql/models.go | 2 +- 5 files changed, 60 insertions(+), 35 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 3f4f5dd..5222930 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -44,8 +44,8 @@ type ReleaseSeason string // Studio defines model for Studio. type Studio struct { Description *string `json:"description,omitempty"` - Id *int64 `json:"id,omitempty"` - Name *string `json:"name,omitempty"` + Id int64 `json:"id"` + Name string `json:"name"` Poster *Image `json:"poster,omitempty"` } @@ -639,6 +639,14 @@ func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w ht return json.NewEncoder(w).Encode(response) } +type GetTitleTitleId204Response struct { +} + +func (response GetTitleTitleId204Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + type GetTitleTitleId400Response struct { } diff --git a/api/openapi.yaml b/api/openapi.yaml index 9ea20f4..7c83426 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -94,6 +94,8 @@ paths: description: Request params are not correct '500': description: Unknown server error + '204': + description: No title found # patch: # summary: Update title info @@ -636,6 +638,9 @@ components: Studio: type: object + required: + - id + - name properties: id: type: integer diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 6e3967a..c66fef5 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "database/sql" "encoding/json" "fmt" oapi "nyanimedb/api" @@ -11,13 +12,10 @@ import ( ) func Word2Sqlc(s *string) *string { - // TODO: merge two ifs - if s == nil { - return nil - } - if *s == "" { + if s == nil || *s == "" { return nil } + return s } @@ -59,21 +57,19 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } -// type TitleNames map[string][]string -// type EpisodeLens map[string]float64 -// type TagNames []map[string]string - func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } return nil, fmt.Errorf("query GetTitleTags: %v", err) } - // TODO: check if underlying slice initialized as nil - var oapi_tag_names oapi.Tags + oapi_tag_names := make(oapi.Tags, 1) for _, title_tag := range sqlc_title_tags { - var oapi_tag_name map[string]string + oapi_tag_name := make(map[string]string, 1) err = json.Unmarshal(title_tag, &oapi_tag_name) if err != nil { return nil, fmt.Errorf("unmarshalling title_tag: %v", err) @@ -84,58 +80,65 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return oapi_tag_names, nil } -func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { +func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image oapi.Image + var oapi_image *oapi.Image sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } - // TODO: clearer comment - //can cast and dont use brain cause all this fiels required + //can cast and dont use brain cause all this fields required in image table oapi_image.Id = &sqlc_image.ID oapi_image.ImagePath = &sqlc_image.ImagePath - storageTypeStr := string(sqlc_image.StorageType) // или fmt.Sprint(...), если int + storageTypeStr := string(sqlc_image.StorageType) oapi_image.StorageType = &storageTypeStr return oapi_image, nil } -func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { - // TODO: check all possibly nil fields +func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { + var oapi_studio oapi.Studio sqlc_studio, err := s.db.GetStudioByID(ctx, id) if err != nil { - return oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) + if err == sql.ErrNoRows { + return nil, nil + } + return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) } - oapi_studio.Id = &sqlc_studio.ID + oapi_studio.Id = sqlc_studio.ID oapi_studio.Name = sqlc_studio.StudioName oapi_studio.Description = sqlc_studio.StudioDesc oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) if err != nil { - return oapi_studio, fmt.Errorf("GetImage: %v", err) + return &oapi_studio, fmt.Errorf("GetImage: %v", err) + } + if oapi_illust != nil { + oapi_studio.Poster = oapi_illust } - oapi_studio.Poster = &oapi_illust - return oapi_studio, nil + return &oapi_studio, nil } func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { - // TODO: check all possibly nil fields + var oapi_title oapi.Title - var title_names map[string][]string + title_names := make(map[string][]string, 1) err := json.Unmarshal(title.TitleNames, &title_names) if err != nil { return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) } - var episodes_lens map[string]float64 + episodes_lens := make(map[string]float64, 1) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) @@ -145,16 +148,25 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err if err != nil { return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) } + if oapi_tag_names != nil { + oapi_title.Tags = oapi_tag_names + } oapi_image, err := s.GetImage(ctx, *title.PosterID) if err != nil { return oapi_title, fmt.Errorf("GetImage: %v", err) } + if oapi_image != nil { + oapi_title.Poster = oapi_image + } oapi_studio, err := s.GetStudio(ctx, title.StudioID) if err != nil { return oapi_title, fmt.Errorf("GetStudio: %v", err) } + if oapi_studio != nil { + oapi_title.Studio = oapi_studio + } if title.ReleaseSeason != nil { rs := oapi.ReleaseSeason(*title.ReleaseSeason) @@ -167,12 +179,9 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err oapi_title.TitleStatus = &ts oapi_title.Id = title.ID - oapi_title.Poster = &oapi_image oapi_title.Rating = title.Rating oapi_title.RatingCount = title.RatingCount oapi_title.ReleaseYear = title.ReleaseYear - oapi_title.Studio = &oapi_studio - oapi_title.Tags = oapi_tag_names oapi_title.TitleNames = title_names oapi_title.EpisodesAired = title.EpisodesAired oapi_title.EpisodesAll = title.EpisodesAll @@ -186,6 +195,9 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { + if err == sql.ErrNoRows { + return oapi.GetTitleTitleId204Response{}, nil + } log.Errorf("%v", err) return oapi.GetTitleTitleId500Response{}, nil } @@ -200,7 +212,7 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { - var opai_titles []oapi.Title + opai_titles := make([]oapi.Title, 1) word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 97f881d..81f2801 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -54,7 +54,7 @@ CREATE TABLE users ( CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - studio_name text UNIQUE, + studio_name text NOT NULL UNIQUE, illust_id bigint REFERENCES images (id), studio_desc text ); diff --git a/sql/models.go b/sql/models.go index a36c6fa..a593504 100644 --- a/sql/models.go +++ b/sql/models.go @@ -233,7 +233,7 @@ type Signal struct { type Studio struct { ID int64 `json:"id"` - StudioName *string `json:"studio_name"` + StudioName string `json:"studio_name"` IllustID *int64 `json:"illust_id"` StudioDesc *string `json:"studio_desc"` } From 148ae646b1d301d8f71b1bf0a2fe4671a84e14b9 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Mon, 17 Nov 2025 09:39:26 +0300 Subject: [PATCH 29/97] feat --- modules/backend/handlers/titles.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index c66fef5..45d4c9b 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -2,12 +2,12 @@ package handlers import ( "context" - "database/sql" "encoding/json" "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "github.com/jackc/pgx/v5" log "github.com/sirupsen/logrus" ) @@ -61,7 +61,7 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return nil, nil } return nil, fmt.Errorf("query GetTitleTags: %v", err) @@ -82,14 +82,14 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image *oapi.Image + var oapi_image oapi.Image sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return nil, nil } - return oapi_image, fmt.Errorf("query GetImageByID: %v", err) + return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) } //can cast and dont use brain cause all this fields required in image table @@ -98,7 +98,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { storageTypeStr := string(sqlc_image.StorageType) oapi_image.StorageType = &storageTypeStr - return oapi_image, nil + return &oapi_image, nil } func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { @@ -107,7 +107,7 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { sqlc_studio, err := s.db.GetStudioByID(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return nil, nil } return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) @@ -195,7 +195,7 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return oapi.GetTitleTitleId204Response{}, nil } log.Errorf("%v", err) From 83711211300f3dcb00eabe35b0bb38aec9aae4f1 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Tue, 18 Nov 2025 03:12:16 +0300 Subject: [PATCH 30/97] fix --- modules/backend/handlers/titles.go | 17 +++++++++++------ sql/migrations/000001_init.up.sql | 2 ++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 45d4c9b..9f076d5 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -117,6 +117,9 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { oapi_studio.Name = sqlc_studio.StudioName oapi_studio.Description = sqlc_studio.StudioDesc + if sqlc_studio.IllustID == nil { + return &oapi_studio, nil + } oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) if err != nil { return &oapi_studio, fmt.Errorf("GetImage: %v", err) @@ -152,12 +155,14 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err oapi_title.Tags = oapi_tag_names } - oapi_image, err := s.GetImage(ctx, *title.PosterID) - if err != nil { - return oapi_title, fmt.Errorf("GetImage: %v", err) - } - if oapi_image != nil { - oapi_title.Poster = oapi_image + if title.PosterID != nil { + oapi_image, err := s.GetImage(ctx, *title.PosterID) + if err != nil { + return oapi_title, fmt.Errorf("GetImage: %v", err) + } + if oapi_image != nil { + oapi_title.Poster = oapi_image + } } oapi_studio, err := s.GetStudio(ctx, title.StudioID) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 81f2801..8bd40d1 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -60,6 +60,7 @@ CREATE TABLE studios ( ); CREATE TABLE titles ( + // TODO: anime type (film, season etc) id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, @@ -88,6 +89,7 @@ CREATE TABLE usertitles ( rate int CHECK (rate > 0 AND rate <= 10), review_text text, review_date timestamptz + // TODO: series status ); CREATE TABLE title_tags ( From 8deba7afd9e320d2b182e9888baf1afe9c415c51 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Tue, 18 Nov 2025 04:19:34 +0300 Subject: [PATCH 31/97] fix --- api/openapi.yaml | 22 ++++++++++++++++++++++ modules/backend/handlers/titles.go | 4 ++-- modules/backend/queries.sql | 2 +- sql/migrations/000001_init.up.sql | 7 +++---- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 7c83426..0bbedca 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -757,4 +757,26 @@ components: UserTitle: type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time additionalProperties: true diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 9f076d5..45f0ef0 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -87,7 +87,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { if err == pgx.ErrNoRows { - return nil, nil + return nil, nil //todo: error reference in db } return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) } @@ -217,7 +217,7 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { - opai_titles := make([]oapi.Title, 1) + opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index a4ed1fe..423be37 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,7 +1,7 @@ -- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = sqlc.arg('illust_id'); +WHERE id = sqlc.arg('illust_id')::bigint; -- name: CreateImage :one INSERT INTO images (storage_type, image_path) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 8bd40d1..7b79300 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -28,7 +28,6 @@ CREATE TABLE reviews ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data text NOT NULL, rating int CHECK (rating >= 0 AND rating <= 10), - illust_id bigint REFERENCES images (id), user_id bigint REFERENCES users (id), title_id bigint REFERENCES titles (id), created_at timestamptz DEFAULT NOW() @@ -87,9 +86,9 @@ CREATE TABLE usertitles ( title_id bigint NOT NULL REFERENCES titles (id), status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_text text, - review_date timestamptz - // TODO: series status + review_id bigint NOT NULL REFERENCES reviews (id), + ctime timestamptz + -- // TODO: series status ); CREATE TABLE title_tags ( From 09d0d1eb4d511c91c4ec4d20f1c7237f9b9b06e7 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Tue, 18 Nov 2025 04:59:19 +0300 Subject: [PATCH 32/97] feat: get user titles described --- api/api.gen.go | 447 ++++++++++++++++++++++++----- api/openapi.yaml | 99 ++++--- modules/backend/handlers/titles.go | 30 +- modules/backend/handlers/users.go | 7 + sql/migrations/000001_init.up.sql | 2 +- 5 files changed, 461 insertions(+), 124 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 5222930..74a2d52 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -26,9 +26,17 @@ const ( // Defines values for TitleStatus. const ( - Finished TitleStatus = "finished" - Ongoing TitleStatus = "ongoing" - Planned TitleStatus = "planned" + TitleStatusFinished TitleStatus = "finished" + TitleStatusOngoing TitleStatus = "ongoing" + TitleStatusPlanned TitleStatus = "planned" +) + +// Defines values for UserTitleStatus. +const ( + UserTitleStatusDropped UserTitleStatus = "dropped" + UserTitleStatusFinished UserTitleStatus = "finished" + UserTitleStatusInProgress UserTitleStatus = "in-progress" + UserTitleStatusPlanned UserTitleStatus = "planned" ) // Image defines model for Image. @@ -110,8 +118,27 @@ type User struct { UserDesc *string `json:"user_desc,omitempty"` } -// GetTitleParams defines parameters for GetTitle. -type GetTitleParams struct { +// UserTitle defines model for UserTitle. +type UserTitle struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// UserTitleStatus User's title status +type UserTitleStatus string + +// Cursor defines model for cursor. +type Cursor = string + +// GetTitlesParams defines parameters for GetTitles. +type GetTitlesParams struct { Word *string `form:"word,omitempty" json:"word,omitempty"` Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` @@ -122,8 +149,8 @@ type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetTitleTitleIdParams defines parameters for GetTitleTitleId. -type GetTitleTitleIdParams struct { +// GetTitlesTitleIdParams defines parameters for GetTitlesTitleId. +type GetTitlesTitleIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } @@ -132,6 +159,15 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. +type GetUsersUserIdTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Query *string `form:"query,omitempty" json:"query,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -374,17 +410,157 @@ func (a Title) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// Getter for additional properties for UserTitle. Returns the specified +// element and whether it was found +func (a UserTitle) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for UserTitle +func (a *UserTitle) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for UserTitle to handle AdditionalProperties +func (a *UserTitle) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["ctime"]; found { + err = json.Unmarshal(raw, &a.Ctime) + if err != nil { + return fmt.Errorf("error reading 'ctime': %w", err) + } + delete(object, "ctime") + } + + if raw, found := object["rate"]; found { + err = json.Unmarshal(raw, &a.Rate) + if err != nil { + return fmt.Errorf("error reading 'rate': %w", err) + } + delete(object, "rate") + } + + if raw, found := object["review_id"]; found { + err = json.Unmarshal(raw, &a.ReviewId) + if err != nil { + return fmt.Errorf("error reading 'review_id': %w", err) + } + delete(object, "review_id") + } + + if raw, found := object["status"]; found { + err = json.Unmarshal(raw, &a.Status) + if err != nil { + return fmt.Errorf("error reading 'status': %w", err) + } + delete(object, "status") + } + + if raw, found := object["title_id"]; found { + err = json.Unmarshal(raw, &a.TitleId) + if err != nil { + return fmt.Errorf("error reading 'title_id': %w", err) + } + delete(object, "title_id") + } + + if raw, found := object["user_id"]; found { + err = json.Unmarshal(raw, &a.UserId) + if err != nil { + return fmt.Errorf("error reading 'user_id': %w", err) + } + delete(object, "user_id") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for UserTitle to handle AdditionalProperties +func (a UserTitle) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.Ctime != nil { + object["ctime"], err = json.Marshal(a.Ctime) + if err != nil { + return nil, fmt.Errorf("error marshaling 'ctime': %w", err) + } + } + + if a.Rate != nil { + object["rate"], err = json.Marshal(a.Rate) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rate': %w", err) + } + } + + if a.ReviewId != nil { + object["review_id"], err = json.Marshal(a.ReviewId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'review_id': %w", err) + } + } + + object["status"], err = json.Marshal(a.Status) + if err != nil { + return nil, fmt.Errorf("error marshaling 'status': %w", err) + } + + object["title_id"], err = json.Marshal(a.TitleId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_id': %w", err) + } + + object["user_id"], err = json.Marshal(a.UserId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'user_id': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles - // (GET /title) - GetTitle(c *gin.Context, params GetTitleParams) + // (GET /titles) + GetTitles(c *gin.Context, params GetTitlesParams) // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(c *gin.Context, titleId int64, params GetTitleTitleIdParams) + // (GET /titles/{title_id}) + GetTitlesTitleId(c *gin.Context, titleId int64, params GetTitlesTitleIdParams) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Get user titles + // (GET /users/{user_id}/titles/) + GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) } // ServerInterfaceWrapper converts contexts to parameters. @@ -396,13 +572,13 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// GetTitle operation middleware -func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { +// GetTitles operation middleware +func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { var err error // Parameter object where we will unmarshal all parameters from the context - var params GetTitleParams + var params GetTitlesParams // ------------- Optional query parameter "word" ------------- @@ -475,11 +651,11 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { } } - siw.Handler.GetTitle(c, params) + siw.Handler.GetTitles(c, params) } -// GetTitleTitleId operation middleware -func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { +// GetTitlesTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { var err error @@ -493,7 +669,7 @@ func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetTitleTitleIdParams + var params GetTitlesTitleIdParams // ------------- Optional query parameter "fields" ------------- @@ -510,7 +686,7 @@ func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { } } - siw.Handler.GetTitleTitleId(c, titleId, params) + siw.Handler.GetTitlesTitleId(c, titleId, params) } // GetUsersUserId operation middleware @@ -548,6 +724,73 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { siw.Handler.GetUsersUserId(c, userId, params) } +// GetUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdTitlesParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdTitles(c, userId, params) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -575,98 +818,99 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) + router.GET(options.BaseURL+"/titles", wrapper.GetTitles) + router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.GET(options.BaseURL+"/users/:user_id/titles/", wrapper.GetUsersUserIdTitles) } -type GetTitleRequestObject struct { - Params GetTitleParams +type GetTitlesRequestObject struct { + Params GetTitlesParams } -type GetTitleResponseObject interface { - VisitGetTitleResponse(w http.ResponseWriter) error +type GetTitlesResponseObject interface { + VisitGetTitlesResponse(w http.ResponseWriter) error } -type GetTitle200JSONResponse []Title +type GetTitles200JSONResponse []Title -func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles200JSONResponse) VisitGetTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitle204Response struct { +type GetTitles204Response struct { } -func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles204Response) VisitGetTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetTitle400Response struct { +type GetTitles400Response struct { } -func (response GetTitle400Response) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles400Response) VisitGetTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetTitle500Response struct { +type GetTitles500Response struct { } -func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles500Response) VisitGetTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } -type GetTitleTitleIdRequestObject struct { +type GetTitlesTitleIdRequestObject struct { TitleId int64 `json:"title_id"` - Params GetTitleTitleIdParams + Params GetTitlesTitleIdParams } -type GetTitleTitleIdResponseObject interface { - VisitGetTitleTitleIdResponse(w http.ResponseWriter) error +type GetTitlesTitleIdResponseObject interface { + VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error } -type GetTitleTitleId200JSONResponse Title +type GetTitlesTitleId200JSONResponse Title -func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId200JSONResponse) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitleTitleId204Response struct { +type GetTitlesTitleId204Response struct { } -func (response GetTitleTitleId204Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId204Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetTitleTitleId400Response struct { +type GetTitlesTitleId400Response struct { } -func (response GetTitleTitleId400Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId400Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetTitleTitleId404Response struct { +type GetTitlesTitleId404Response struct { } -func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId404Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetTitleTitleId500Response struct { +type GetTitlesTitleId500Response struct { } -func (response GetTitleTitleId500Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId500Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -713,17 +957,62 @@ func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type GetUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdTitlesParams +} + +type GetUsersUserIdTitlesResponseObject interface { + VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdTitles200JSONResponse []UserTitle + +func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitles204Response struct { +} + +func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsersUserIdTitles400Response struct { +} + +func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsersUserIdTitles500Response struct { +} + +func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles - // (GET /title) - GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // (GET /titles) + GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) + // (GET /titles/{title_id}) + GetTitlesTitleId(ctx context.Context, request GetTitlesTitleIdRequestObject) (GetTitlesTitleIdResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Get user titles + // (GET /users/{user_id}/titles/) + GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -738,17 +1027,17 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// GetTitle operation middleware -func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { - var request GetTitleRequestObject +// GetTitles operation middleware +func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { + var request GetTitlesRequestObject request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + return sh.ssi.GetTitles(ctx, request.(GetTitlesRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitle") + handler = middleware(handler, "GetTitles") } response, err := handler(ctx, request) @@ -756,8 +1045,8 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleResponseObject); ok { - if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitlesResponseObject); ok { + if err := validResponse.VisitGetTitlesResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -765,18 +1054,18 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } -// GetTitleTitleId operation middleware -func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId int64, params GetTitleTitleIdParams) { - var request GetTitleTitleIdRequestObject +// GetTitlesTitleId operation middleware +func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, params GetTitlesTitleIdParams) { + var request GetTitlesTitleIdRequestObject request.TitleId = titleId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) + return sh.ssi.GetTitlesTitleId(ctx, request.(GetTitlesTitleIdRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitleTitleId") + handler = middleware(handler, "GetTitlesTitleId") } response, err := handler(ctx, request) @@ -784,8 +1073,8 @@ func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId int64, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { - if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitlesTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitlesTitleIdResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -820,3 +1109,31 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// GetUsersUserIdTitles operation middleware +func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { + var request GetUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index 0bbedca..bc7fb15 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -7,7 +7,7 @@ servers: - url: /api/v1 paths: - /title: + /titles: get: summary: Get titles parameters: @@ -66,7 +66,7 @@ paths: '500': description: Unknown server error - /title/{title_id}: + /titles/{title_id}: get: summary: Get title description parameters: @@ -126,7 +126,7 @@ paths: # user_json: # $ref: '#/components/schemas/User' -# /title/{title_id}/reviews: +# /titles/{title_id}/reviews: # get: # summary: Get title reviews # parameters: @@ -278,45 +278,50 @@ paths: # user_json: # $ref: '#/components/schemas/User' -# /users/{user_id}/titles: -# get: -# summary: Get user titles -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: query -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# - in: query -# name: fields -# schema: -# type: string -# default: all -# responses: -# '200': -# description: List of user titles -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/UserTitle' -# '204': -# description: No titles found + /users/{user_id}/titles/: + get: + summary: Get user titles + parameters: + - $ref: '#/components/parameters/cursor' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: query + schema: + type: string + - in: query + name: limit + schema: + type: integer + default: 10 + - in: query + name: offset + schema: + type: integer + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserTitle' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error # post: # summary: Add user title @@ -571,6 +576,14 @@ paths: # type: string components: + parameters: + cursor: + in: query + name: cursor + required: false + schema: + type: string + schemas: Image: type: object @@ -769,7 +782,7 @@ components: type: integer format: int64 status: - $ref: '#components/schemas/UserTitleStatus' + $ref: '#/components/schemas/UserTitleStatus' rate: type: integer format: int32 diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 45f0ef0..46ff982 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -25,11 +25,11 @@ func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { } var t sqlc.TitleStatusT switch *s { - case oapi.Finished: + case oapi.TitleStatusFinished: t = sqlc.TitleStatusTFinished - case oapi.Ongoing: + case oapi.TitleStatusOngoing: t = sqlc.TitleStatusTOngoing - case oapi.Planned: + case oapi.TitleStatusPlanned: t = sqlc.TitleStatusTPlanned default: return nil, fmt.Errorf("unexpected tittle status: %s", *s) @@ -195,40 +195,40 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err return oapi_title, nil } -func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleIdRequestObject) (oapi.GetTitleTitleIdResponseObject, error) { +func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { var oapi_title oapi.Title sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetTitleTitleId204Response{}, nil + return oapi.GetTitlesTitleId204Response{}, nil } log.Errorf("%v", err) - return oapi.GetTitleTitleId500Response{}, nil + return oapi.GetTitlesTitleId500Response{}, nil } oapi_title, err = s.mapTitle(ctx, sqlc_title) if err != nil { log.Errorf("%v", err) - return oapi.GetTitleTitleId500Response{}, nil + return oapi.GetTitlesTitleId500Response{}, nil } - return oapi.GetTitleTitleId200JSONResponse(oapi_title), nil + return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil } -func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { +func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle400Response{}, err + return oapi.GetTitles400Response{}, err } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle400Response{}, err + return oapi.GetTitles400Response{}, err } // param = nil means it will not be used titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ @@ -242,10 +242,10 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject }) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle500Response{}, nil + return oapi.GetTitles500Response{}, nil } if len(titles) == 0 { - return oapi.GetTitle204Response{}, nil + return oapi.GetTitles204Response{}, nil } for _, title := range titles { @@ -253,10 +253,10 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject t, err := s.mapTitle(ctx, title) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle500Response{}, nil + return oapi.GetTitles500Response{}, nil } opai_titles = append(opai_titles, t) } - return oapi.GetTitle200JSONResponse(opai_titles), nil + return oapi.GetTitles200JSONResponse(opai_titles), nil } diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 9e59261..2179977 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -48,3 +48,10 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque } return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil } + +func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { + + // oapi_user_titles := make([]oapi.UserTitle, 0) + + return nil, nil +} diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 7b79300..49cca3d 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,7 @@ CREATE TABLE usertitles ( title_id bigint NOT NULL REFERENCES titles (id), status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_id bigint NOT NULL REFERENCES reviews (id), + review_id bigint REFERENCES reviews (id), ctime timestamptz -- // TODO: series status ); From 6836cfa057a7cea8fd7d2ec5b8dac202190d114c Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Tue, 18 Nov 2025 05:11:35 +0300 Subject: [PATCH 33/97] feat: stub for get user titles written --- api/openapi.yaml | 2 +- modules/backend/handlers/users.go | 30 ++++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index bc7fb15..a33fe89 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -233,7 +233,7 @@ paths: # error: # type: string - /users: + # /users: # get: # summary: Search user # parameters: diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 2179977..0fa903f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -4,6 +4,7 @@ import ( "context" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "time" "github.com/jackc/pgx/v5" "github.com/oapi-codegen/runtime/types" @@ -51,7 +52,32 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { - // oapi_user_titles := make([]oapi.UserTitle, 0) + var rate int32 = 9 + var review_id int64 = 3 + time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) - return nil, nil + var userTitles = []oapi.UserTitle{ + { + UserId: 101, + TitleId: 2001, + Status: oapi.UserTitleStatusFinished, + Rate: &rate, + Ctime: &time, + }, + { + UserId: 102, + TitleId: 2002, + Status: oapi.UserTitleStatusInProgress, + ReviewId: &review_id, + Ctime: &time, + }, + { + UserId: 103, + TitleId: 2003, + Status: oapi.UserTitleStatusDropped, + Ctime: &time, + }, + } + + return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil } From b976c35b8ead35dae8511f77a344559dc5b56418 Mon Sep 17 00:00:00 2001 From: nihonium Date: Tue, 18 Nov 2025 05:15:38 +0300 Subject: [PATCH 34/97] feat: titles page --- modules/frontend/src/App.tsx | 4 +- modules/frontend/src/api/index.ts | 7 + modules/frontend/src/api/models/Image.ts | 10 + .../frontend/src/api/models/ReleaseSeason.ts | 13 + modules/frontend/src/api/models/Studio.ts | 12 + modules/frontend/src/api/models/Tag.ts | 5 +- modules/frontend/src/api/models/Tags.ts | 9 + .../frontend/src/api/models/TitleStatus.ts | 12 + modules/frontend/src/api/models/User.ts | 4 +- .../src/api/models/UserTitleStatus.ts | 13 + modules/frontend/src/api/models/cursor.ts | 5 + .../src/api/services/DefaultService.ts | 113 ++++++ modules/frontend/src/api_/core/ApiError.ts | 25 ++ .../src/api_/core/ApiRequestOptions.ts | 17 + modules/frontend/src/api_/core/ApiResult.ts | 11 + .../src/api_/core/CancelablePromise.ts | 131 +++++++ modules/frontend/src/api_/core/OpenAPI.ts | 32 ++ modules/frontend/src/api_/core/request.ts | 323 ++++++++++++++++++ modules/frontend/src/api_/index.ts | 23 ++ modules/frontend/src/api_/models/Image.ts | 10 + .../frontend/src/api_/models/ReleaseSeason.ts | 13 + modules/frontend/src/api_/models/Review.ts | 5 + modules/frontend/src/api_/models/Studio.ts | 12 + modules/frontend/src/api_/models/Tag.ts | 8 + modules/frontend/src/api_/models/Tags.ts | 9 + modules/frontend/src/api_/models/Title.ts | 5 + .../frontend/src/api_/models/TitleStatus.ts | 12 + modules/frontend/src/api_/models/User.ts | 35 ++ modules/frontend/src/api_/models/UserTitle.ts | 5 + .../src/api_/models/UserTitleStatus.ts | 13 + modules/frontend/src/api_/models/cursor.ts | 5 + .../src/api_/services/DefaultService.ts | 148 ++++++++ .../src/components/ListView/ListView.tsx | 52 +++ .../src/components/ListView/useListView.tsx | 37 ++ .../components/UserPage/UserPage.module.css | 97 ------ .../components/cards/TitleCardHorizontal.tsx | 22 ++ .../src/components/cards/TitleCardSquare.tsx | 22 ++ .../src/pages/TitlePage/TitlePage.module.css | 0 .../src/pages/TitlePage/TitlePage.tsx | 64 ++++ .../pages/TitlesPage/TitlesPage.module.css | 59 ++++ .../src/pages/TitlesPage/TitlesPage.tsx | 114 +++++++ .../src/pages/UserPage/UserPage.module.css | 103 ++++++ .../UserPage/UserPage.tsx | 15 +- modules/frontend/src/types/list.ts | 12 + 44 files changed, 1539 insertions(+), 107 deletions(-) create mode 100644 modules/frontend/src/api/models/Image.ts create mode 100644 modules/frontend/src/api/models/ReleaseSeason.ts create mode 100644 modules/frontend/src/api/models/Studio.ts create mode 100644 modules/frontend/src/api/models/Tags.ts create mode 100644 modules/frontend/src/api/models/TitleStatus.ts create mode 100644 modules/frontend/src/api/models/UserTitleStatus.ts create mode 100644 modules/frontend/src/api/models/cursor.ts create mode 100644 modules/frontend/src/api_/core/ApiError.ts create mode 100644 modules/frontend/src/api_/core/ApiRequestOptions.ts create mode 100644 modules/frontend/src/api_/core/ApiResult.ts create mode 100644 modules/frontend/src/api_/core/CancelablePromise.ts create mode 100644 modules/frontend/src/api_/core/OpenAPI.ts create mode 100644 modules/frontend/src/api_/core/request.ts create mode 100644 modules/frontend/src/api_/index.ts create mode 100644 modules/frontend/src/api_/models/Image.ts create mode 100644 modules/frontend/src/api_/models/ReleaseSeason.ts create mode 100644 modules/frontend/src/api_/models/Review.ts create mode 100644 modules/frontend/src/api_/models/Studio.ts create mode 100644 modules/frontend/src/api_/models/Tag.ts create mode 100644 modules/frontend/src/api_/models/Tags.ts create mode 100644 modules/frontend/src/api_/models/Title.ts create mode 100644 modules/frontend/src/api_/models/TitleStatus.ts create mode 100644 modules/frontend/src/api_/models/User.ts create mode 100644 modules/frontend/src/api_/models/UserTitle.ts create mode 100644 modules/frontend/src/api_/models/UserTitleStatus.ts create mode 100644 modules/frontend/src/api_/models/cursor.ts create mode 100644 modules/frontend/src/api_/services/DefaultService.ts create mode 100644 modules/frontend/src/components/ListView/ListView.tsx create mode 100644 modules/frontend/src/components/ListView/useListView.tsx delete mode 100644 modules/frontend/src/components/UserPage/UserPage.module.css create mode 100644 modules/frontend/src/components/cards/TitleCardHorizontal.tsx create mode 100644 modules/frontend/src/components/cards/TitleCardSquare.tsx create mode 100644 modules/frontend/src/pages/TitlePage/TitlePage.module.css create mode 100644 modules/frontend/src/pages/TitlePage/TitlePage.tsx create mode 100644 modules/frontend/src/pages/TitlesPage/TitlesPage.module.css create mode 100644 modules/frontend/src/pages/TitlesPage/TitlesPage.tsx create mode 100644 modules/frontend/src/pages/UserPage/UserPage.module.css rename modules/frontend/src/{components => pages}/UserPage/UserPage.tsx (88%) create mode 100644 modules/frontend/src/types/list.ts diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index a88ad57..1256086 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,12 +1,14 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UserPage from "./components/UserPage/UserPage"; +import UserPage from "./pages/UserPage/UserPage"; +import TitlesPage from "./pages/UserPage/UserPage"; const App: React.FC = () => { return ( } /> + } /> ); diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index e4f4ef4..f0d09ee 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,10 +7,17 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { cursor } from './models/cursor'; +export type { Image } from './models/Image'; +export { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; +export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; +export type { Tags } from './models/Tags'; export type { Title } from './models/Title'; +export { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; +export { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts new file mode 100644 index 0000000..1317db7 --- /dev/null +++ b/modules/frontend/src/api/models/Image.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Image = { + id?: number; + storage_type?: string; + image_path?: string; +}; + diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts new file mode 100644 index 0000000..182b980 --- /dev/null +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title release season + */ +export enum ReleaseSeason { + WINTER = 'winter', + SPRING = 'spring', + SUMMER = 'summer', + FALL = 'fall', +} diff --git a/modules/frontend/src/api/models/Studio.ts b/modules/frontend/src/api/models/Studio.ts new file mode 100644 index 0000000..062695a --- /dev/null +++ b/modules/frontend/src/api/models/Studio.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Image } from './Image'; +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts index 9560ea8..665c724 100644 --- a/modules/frontend/src/api/models/Tag.ts +++ b/modules/frontend/src/api/models/Tag.ts @@ -2,4 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Tag = Record; +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = Record; diff --git a/modules/frontend/src/api/models/Tags.ts b/modules/frontend/src/api/models/Tags.ts new file mode 100644 index 0000000..748f066 --- /dev/null +++ b/modules/frontend/src/api/models/Tags.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Tag } from './Tag'; +/** + * Array of localized tags + */ +export type Tags = Array; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts new file mode 100644 index 0000000..811ece8 --- /dev/null +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title status + */ +export enum TitleStatus { + FINISHED = 'finished', + ONGOING = 'ongoing', + PLANNED = 'planned', +} diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index b03d22f..541028e 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -6,7 +6,7 @@ export type User = { /** * Unique user ID (primary key) */ - id?: number; + id: number; /** * ID of the user avatar (references images table) */ @@ -30,6 +30,6 @@ export type User = { /** * Timestamp when the user was created */ - creation_date: string; + creation_date?: string; }; diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts new file mode 100644 index 0000000..20651fe --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User's title status + */ +export enum UserTitleStatus { + FINISHED = 'finished', + PLANNED = 'planned', + DROPPED = 'dropped', + IN_PROGRESS = 'in-progress', +} diff --git a/modules/frontend/src/api/models/cursor.ts b/modules/frontend/src/api/models/cursor.ts new file mode 100644 index 0000000..5788e14 --- /dev/null +++ b/modules/frontend/src/api/models/cursor.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type cursor = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 7ebd129..b0ae54d 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -2,11 +2,84 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { ReleaseSeason } from '../models/ReleaseSeason'; +import type { Title } from '../models/Title'; +import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; +import type { UserTitle } from '../models/UserTitle'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class DefaultService { + /** + * Get titles + * @param word + * @param status + * @param rating + * @param releaseYear + * @param releaseSeason + * @param limit + * @param offset + * @param fields + * @returns Title List of titles + * @throws ApiError + */ + public static getTitles( + word?: string, + status?: TitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles', + query: { + 'word': word, + 'status': status, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get title description + * @param titleId + * @param fields + * @returns Title Title description + * @throws ApiError + */ + public static getTitles1( + titleId: number, + fields: string = 'all', + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/titles/{title_id}', + path: { + 'title_id': titleId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `Title not found`, + 500: `Unknown server error`, + }, + }); + } /** * Get user info * @param userId @@ -28,7 +101,47 @@ export class DefaultService { 'fields': fields, }, errors: { + 400: `Request params are not correct`, 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user titles + * @param userId + * @param cursor + * @param query + * @param limit + * @param offset + * @param fields + * @returns UserTitle List of user titles + * @throws ApiError + */ + public static getUsersTitles( + userId: string, + cursor?: string, + query?: string, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<UserTitle>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'cursor': cursor, + 'query': query, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, }, }); } diff --git a/modules/frontend/src/api_/core/ApiError.ts b/modules/frontend/src/api_/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/api_/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/api_/core/ApiRequestOptions.ts b/modules/frontend/src/api_/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/api_/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record<string, any>; + readonly cookies?: Record<string, any>; + readonly headers?: Record<string, any>; + readonly query?: Record<string, any>; + readonly formData?: Record<string, any>; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record<number, string>; +}; diff --git a/modules/frontend/src/api_/core/ApiResult.ts b/modules/frontend/src/api_/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/api_/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/api_/core/CancelablePromise.ts b/modules/frontend/src/api_/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/api_/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise<T> implements Promise<T> { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise<T>; + #resolve?: (value: T | PromiseLike<T>) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike<T>) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise<T>((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike<T>): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then<TResult1 = T, TResult2 = never>( + onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null + ): Promise<TResult1 | TResult2> { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch<TResult = never>( + onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null + ): Promise<T | TResult> { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise<T> { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/api_/core/OpenAPI.ts b/modules/frontend/src/api_/core/OpenAPI.ts new file mode 100644 index 0000000..185e5c3 --- /dev/null +++ b/modules/frontend/src/api_/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; +type Headers = Record<string, string>; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver<string> | undefined; + USERNAME?: string | Resolver<string> | undefined; + PASSWORD?: string | Resolver<string> | undefined; + HEADERS?: Headers | Resolver<Headers> | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: '/api/v1', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/api_/core/request.ts b/modules/frontend/src/api_/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/api_/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record<string, any>): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; + +export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { + if (typeof resolver === 'function') { + return (resolver as Resolver<T>)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record<string, string>); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async <T>( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record<string, string>, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise<AxiosResponse<T>> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError<T>; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse<any>): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record<number, string> = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise<T> + * @throws ApiError + */ +export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/api_/index.ts b/modules/frontend/src/api_/index.ts new file mode 100644 index 0000000..f0d09ee --- /dev/null +++ b/modules/frontend/src/api_/index.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export type { cursor } from './models/cursor'; +export type { Image } from './models/Image'; +export { ReleaseSeason } from './models/ReleaseSeason'; +export type { Review } from './models/Review'; +export type { Studio } from './models/Studio'; +export type { Tag } from './models/Tag'; +export type { Tags } from './models/Tags'; +export type { Title } from './models/Title'; +export { TitleStatus } from './models/TitleStatus'; +export type { User } from './models/User'; +export type { UserTitle } from './models/UserTitle'; +export { UserTitleStatus } from './models/UserTitleStatus'; + +export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api_/models/Image.ts b/modules/frontend/src/api_/models/Image.ts new file mode 100644 index 0000000..1317db7 --- /dev/null +++ b/modules/frontend/src/api_/models/Image.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Image = { + id?: number; + storage_type?: string; + image_path?: string; +}; + diff --git a/modules/frontend/src/api_/models/ReleaseSeason.ts b/modules/frontend/src/api_/models/ReleaseSeason.ts new file mode 100644 index 0000000..182b980 --- /dev/null +++ b/modules/frontend/src/api_/models/ReleaseSeason.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title release season + */ +export enum ReleaseSeason { + WINTER = 'winter', + SPRING = 'spring', + SUMMER = 'summer', + FALL = 'fall', +} diff --git a/modules/frontend/src/api_/models/Review.ts b/modules/frontend/src/api_/models/Review.ts new file mode 100644 index 0000000..9b453b7 --- /dev/null +++ b/modules/frontend/src/api_/models/Review.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Review = Record<string, any>; diff --git a/modules/frontend/src/api_/models/Studio.ts b/modules/frontend/src/api_/models/Studio.ts new file mode 100644 index 0000000..062695a --- /dev/null +++ b/modules/frontend/src/api_/models/Studio.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Image } from './Image'; +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + diff --git a/modules/frontend/src/api_/models/Tag.ts b/modules/frontend/src/api_/models/Tag.ts new file mode 100644 index 0000000..665c724 --- /dev/null +++ b/modules/frontend/src/api_/models/Tag.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = Record<string, string>; diff --git a/modules/frontend/src/api_/models/Tags.ts b/modules/frontend/src/api_/models/Tags.ts new file mode 100644 index 0000000..748f066 --- /dev/null +++ b/modules/frontend/src/api_/models/Tags.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Tag } from './Tag'; +/** + * Array of localized tags + */ +export type Tags = Array<Tag>; diff --git a/modules/frontend/src/api_/models/Title.ts b/modules/frontend/src/api_/models/Title.ts new file mode 100644 index 0000000..4da7aa3 --- /dev/null +++ b/modules/frontend/src/api_/models/Title.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Title = Record<string, any>; diff --git a/modules/frontend/src/api_/models/TitleStatus.ts b/modules/frontend/src/api_/models/TitleStatus.ts new file mode 100644 index 0000000..811ece8 --- /dev/null +++ b/modules/frontend/src/api_/models/TitleStatus.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title status + */ +export enum TitleStatus { + FINISHED = 'finished', + ONGOING = 'ongoing', + PLANNED = 'planned', +} diff --git a/modules/frontend/src/api_/models/User.ts b/modules/frontend/src/api_/models/User.ts new file mode 100644 index 0000000..541028e --- /dev/null +++ b/modules/frontend/src/api_/models/User.ts @@ -0,0 +1,35 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type User = { + /** + * Unique user ID (primary key) + */ + id: number; + /** + * ID of the user avatar (references images table) + */ + avatar_id?: number | null; + /** + * User email + */ + mail?: string; + /** + * Username (alphanumeric + _ or -) + */ + nickname: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description + */ + user_desc?: string; + /** + * Timestamp when the user was created + */ + creation_date?: string; +}; + diff --git a/modules/frontend/src/api_/models/UserTitle.ts b/modules/frontend/src/api_/models/UserTitle.ts new file mode 100644 index 0000000..26d5ddc --- /dev/null +++ b/modules/frontend/src/api_/models/UserTitle.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type UserTitle = Record<string, any>; diff --git a/modules/frontend/src/api_/models/UserTitleStatus.ts b/modules/frontend/src/api_/models/UserTitleStatus.ts new file mode 100644 index 0000000..20651fe --- /dev/null +++ b/modules/frontend/src/api_/models/UserTitleStatus.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User's title status + */ +export enum UserTitleStatus { + FINISHED = 'finished', + PLANNED = 'planned', + DROPPED = 'dropped', + IN_PROGRESS = 'in-progress', +} diff --git a/modules/frontend/src/api_/models/cursor.ts b/modules/frontend/src/api_/models/cursor.ts new file mode 100644 index 0000000..5788e14 --- /dev/null +++ b/modules/frontend/src/api_/models/cursor.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type cursor = string; diff --git a/modules/frontend/src/api_/services/DefaultService.ts b/modules/frontend/src/api_/services/DefaultService.ts new file mode 100644 index 0000000..b0ae54d --- /dev/null +++ b/modules/frontend/src/api_/services/DefaultService.ts @@ -0,0 +1,148 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ReleaseSeason } from '../models/ReleaseSeason'; +import type { Title } from '../models/Title'; +import type { TitleStatus } from '../models/TitleStatus'; +import type { User } from '../models/User'; +import type { UserTitle } from '../models/UserTitle'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DefaultService { + /** + * Get titles + * @param word + * @param status + * @param rating + * @param releaseYear + * @param releaseSeason + * @param limit + * @param offset + * @param fields + * @returns Title List of titles + * @throws ApiError + */ + public static getTitles( + word?: string, + status?: TitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<Title>> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles', + query: { + 'word': word, + 'status': status, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get title description + * @param titleId + * @param fields + * @returns Title Title description + * @throws ApiError + */ + public static getTitles1( + titleId: number, + fields: string = 'all', + ): CancelablePromise<Title> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles/{title_id}', + path: { + 'title_id': titleId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `Title not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user info + * @param userId + * @param fields + * @returns User User info + * @throws ApiError + */ + public static getUsers( + userId: string, + fields: string = 'all', + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user titles + * @param userId + * @param cursor + * @param query + * @param limit + * @param offset + * @param fields + * @returns UserTitle List of user titles + * @throws ApiError + */ + public static getUsersTitles( + userId: string, + cursor?: string, + query?: string, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<UserTitle>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'cursor': cursor, + 'query': query, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx new file mode 100644 index 0000000..77fea97 --- /dev/null +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -0,0 +1,52 @@ +import React from "react"; + +interface ListViewProps<TItem> { + hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>; + renderHorizontal: (item: TItem) => React.ReactNode; + renderSquare: (item: TItem) => React.ReactNode; +} + +export function ListView<TItem>({ + hook, + renderHorizontal, + renderSquare +}: ListViewProps<TItem>) { + const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook; + + return ( + <div> + {/* Search + Layout Switcher */} + <div style={{ display: "flex", gap: 8, marginBottom: 16 }}> + <input + placeholder="Search..." + value={search} + onChange={e => setSearch(e.target.value)} + /> + + <button onClick={() => setViewMode("horizontal")}>Horizontal</button> + <button onClick={() => setViewMode("square")}>Square</button> + </div> + + {/* Items */} + <div + style={{ + display: "grid", + gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr", + gap: 12 + }} + > + {items.map(item => + viewMode === "horizontal" + ? renderHorizontal(item) + : renderSquare(item) + )} + </div> + + {hasMore && ( + <button onClick={loadMore} style={{ marginTop: 16 }}> + Load More + </button> + )} + </div> + ); +} \ No newline at end of file diff --git a/modules/frontend/src/components/ListView/useListView.tsx b/modules/frontend/src/components/ListView/useListView.tsx new file mode 100644 index 0000000..20c3597 --- /dev/null +++ b/modules/frontend/src/components/ListView/useListView.tsx @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react"; +import type { FetchFunction } from "../../types/list"; + +export function useListView<TItem>(fetchFn: FetchFunction<TItem>) { + const [items, setItems] = useState<TItem[]>([]); + const [cursor, setCursor] = useState<string | undefined>(); + const [search, setSearch] = useState(""); + const [viewMode, setViewMode] = useState<"horizontal" | "square">("horizontal"); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadItems(true); + }, [search]); + + const loadItems = async (reset = false) => { + setIsLoading(true); + const result = await fetchFn({ + search, + cursor: reset ? undefined : cursor, + }); + + setItems(prev => reset ? result.items : [...prev, ...result.items]); + setCursor(result.nextCursor); + setIsLoading(false); + }; + + return { + items, + search, + setSearch, + viewMode, + setViewMode, + loadMore: () => loadItems(), + hasMore: Boolean(cursor), + isLoading, + }; +} \ No newline at end of file diff --git a/modules/frontend/src/components/UserPage/UserPage.module.css b/modules/frontend/src/components/UserPage/UserPage.module.css deleted file mode 100644 index 75195bf..0000000 --- a/modules/frontend/src/components/UserPage/UserPage.module.css +++ /dev/null @@ -1,97 +0,0 @@ -.container { - display: flex; - justify-content: center; - align-items: flex-start; - padding: 3rem 1rem; - background-color: #f5f6fa; - min-height: 100vh; - font-family: "Inter", sans-serif; -} - -.card { - background-color: #ffffff; - border-radius: 1rem; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); - padding: 2rem; - max-width: 400px; - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.avatar { - margin-bottom: 1.5rem; -} - -.avatarImg { - width: 120px; - height: 120px; - border-radius: 50%; - object-fit: cover; - border: 3px solid #4a90e2; -} - -.avatarPlaceholder { - width: 120px; - height: 120px; - border-radius: 50%; - background-color: #dcdde1; - display: flex; - align-items: center; - justify-content: center; - font-size: 3rem; - color: #4a4a4a; - font-weight: bold; - border: 3px solid #4a90e2; -} - -.info { - display: flex; - flex-direction: column; - align-items: center; -} - -.name { - font-size: 1.8rem; - font-weight: 700; - margin: 0.25rem 0; - color: #2f3640; -} - -.nickname { - font-size: 1rem; - color: #718093; - margin-bottom: 1rem; -} - -.desc { - font-size: 1rem; - color: #353b48; - margin-bottom: 1rem; -} - -.created { - font-size: 0.9rem; - color: #7f8fa6; -} - -.loader { - display: flex; - justify-content: center; - align-items: center; - height: 80vh; - font-size: 1.5rem; - color: #4a90e2; -} - -.error { - display: flex; - justify-content: center; - align-items: center; - height: 80vh; - color: #e84118; - font-weight: 600; - font-size: 1.2rem; -} diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx new file mode 100644 index 0000000..c3a8159 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { Title } from "../../api/models/Title"; + +export function TitleCardHorizontal({ title }: { title: Title }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.posterUrl && ( + <img src={title.posterUrl} width={80} /> + )} + <div> + <h3>{title.name}</h3> + <p>{title.year} · {title.season} · Rating: {title.rating}</p> + <p>Status: {title.status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx new file mode 100644 index 0000000..0fc0339 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -0,0 +1,22 @@ +// TitleCardSquare.tsx +import type { Title } from "../../api/models/Title"; + +export function TitleCardSquare({ title }: { title: Title }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.posterUrl && ( + <img src={title.posterUrl} width={140} /> + )} + <div> + <h4>{title.name}</h4> + <small>{title.year} • {title.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css new file mode 100644 index 0000000..e69de29 diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx new file mode 100644 index 0000000..7fe9de7 --- /dev/null +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -0,0 +1,64 @@ +// import React, { useEffect, useState } from "react"; +// import { useParams } from "react-router-dom"; +// import { DefaultService } from "../../api/services/DefaultService"; +// import type { User } from "../../api/models/User"; +// import styles from "./UserPage.module.css"; + +// const UserPage: React.FC = () => { +// const { id } = useParams<{ id: string }>(); +// const [user, setUser] = useState<User | null>(null); +// const [loading, setLoading] = useState(true); +// const [error, setError] = useState<string | null>(null); + +// useEffect(() => { +// if (!id) return; + +// const getTitleInfo = async () => { +// try { +// const userInfo = await DefaultService.getTitle(id, "all"); +// setUser(userInfo); +// } catch (err) { +// console.error(err); +// setError("Failed to fetch user info."); +// } finally { +// setLoading(false); +// } +// }; +// getTitleInfo(); +// }, [id]); + +// if (loading) return <div className={styles.loader}>Loading...</div>; +// if (error) return <div className={styles.error}>{error}</div>; +// if (!user) return <div className={styles.error}>User not found.</div>; + +// return ( +// <div className={styles.container}> +// <div className={styles.card}> +// <div className={styles.avatar}> +// {user.avatar_id ? ( +// <img +// src={`/images/${user.avatar_id}.png`} +// alt="User Avatar" +// className={styles.avatarImg} +// /> +// ) : ( +// <div className={styles.avatarPlaceholder}> +// {user.disp_name?.[0] || "U"} +// </div> +// )} +// </div> + +// <div className={styles.info}> +// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> +// <p className={styles.nickname}>@{user.nickname}</p> +// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} +// <p className={styles.created}> +// Joined: {new Date(user.creation_date).toLocaleDateString()} +// </p> +// </div> +// </div> +// </div> +// ); +// }; + +// export default UserPage; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css new file mode 100644 index 0000000..9cc728b --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css @@ -0,0 +1,59 @@ +.container { + padding: 24px; +} + +.header { + display: flex; + justify-content: space-between; + margin-bottom: 16px; +} + +.searchInput { + padding: 8px; + width: 240px; +} + +.list { + display: grid; + gap: 12px; +} + +.card { + display: flex; + padding: 10px; + border: 1px solid #ddd; + border-radius: 8px; + gap: 12px; +} + +.poster { + width: 80px; + height: 120px; + object-fit: cover; + border-radius: 4px; +} + +.posterPlaceholder { + width: 80px; + height: 120px; + background: #eee; + display: flex; + align-items: center; + justify-content: center; +} + +.cardInfo { + display: flex; + flex-direction: column; +} + +.loadMore { + margin-top: 16px; + padding: 8px 16px; +} + +.loader, +.error { + padding: 20px; + text-align: center; +} diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx new file mode 100644 index 0000000..438d828 --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from "react"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title } from "../../api/models/Title"; +import styles from "./TitlesPage.module.css"; + +const LIMIT = 20; + +const TitlesPage: React.FC = () => { + const [titles, setTitles] = useState<Title[]>([]); + const [search, setSearch] = useState(""); + const [offset, setOffset] = useState(0); + + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState<string | null>(null); + + const fetchTitles = async (reset: boolean) => { + try { + if (reset) { + setLoading(true); + setOffset(0); + } else { + setLoadingMore(true); + } + + const result = await DefaultService.getTitles( + search || undefined, + undefined, // status + undefined, // rating + undefined, // release_year + undefined, // release_season + LIMIT, + reset ? 0 : offset, + "all" + ); + + if (reset) { + setTitles(result); + } else { + setTitles(prev => [...prev, ...result]); + } + + if (result.length > 0) { + setOffset(prev => prev + LIMIT); + } + + } catch (err) { + console.error(err); + setError("Failed to fetch titles."); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + fetchTitles(true); + }, [search]); + + if (loading) return <div className={styles.loader}>Loading...</div>; + if (error) return <div className={styles.error}>{error}</div>; + + return ( + <div className={styles.container}> + <div className={styles.header}> + <h1>Titles</h1> + + <input + className={styles.searchInput} + placeholder="Search titles..." + value={search} + onChange={e => setSearch(e.target.value)} + /> + </div> + + <div className={styles.list}> + {titles.map((t) => ( + <div key={t.id} className={styles.card}> + {t.poster_id ? ( + <img + src={`/images/${t.poster_id}.png`} + alt="Poster" + className={styles.poster} + /> + ) : ( + <div className={styles.posterPlaceholder}>No Image</div> + )} + + <div className={styles.cardInfo}> + <h3 className={styles.titleName}>{t.name}</h3> + <p className={styles.meta}> + {t.release_year} • {t.release_season} + </p> + <p className={styles.rating}>Rating: {t.rating}</p> + <p className={styles.status}>{t.status}</p> + </div> + </div> + ))} + </div> + + {titles.length > 0 && ( + <button + className={styles.loadMore} + onClick={() => fetchTitles(false)} + disabled={loadingMore} + > + {loadingMore ? "Loading..." : "Load More"} + </button> + )} + </div> + ); +}; + +export default TitlesPage; diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css new file mode 100644 index 0000000..7f350c8 --- /dev/null +++ b/modules/frontend/src/pages/UserPage/UserPage.module.css @@ -0,0 +1,103 @@ +body, +html { + width: 100%; + margin: 0; + background-color: #777; + color: #fff; +} + +html, +body, +#root { + height: 100%; +} + +.header { + width: 100vw; + padding: 30px 40px; + background: #f7f7f7; + display: flex; + align-items: center; + gap: 25px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + border-bottom: 1px solid #e5e5e5; + color: #000000; +} + +.avatarWrapper { + width: 120px; + height: 120px; + min-width: 120px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #ddd; +} + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatarPlaceholder { + width: 100%; + height: 100%; + border-radius: 50%; + background: #ccc; + font-size: 42px; + font-weight: bold; + color: #555; + display: flex; + align-items: center; + justify-content: center; +} + +.userInfo { + display: flex; + flex-direction: column; +} + +.name { + font-size: 32px; + font-weight: 700; + margin: 0; +} + +.nickname { + font-size: 18px; + color: #666; + margin-top: 6px; +} + +.container { + max-width: 100vw; + width: 100%; + position: absolute; + top: 0%; + /* margin: 25px auto; */ + /* padding: 0 20px; */ +} + +.content { + margin-top: 20px; +} + +.desc { + font-size: 18px; + margin-bottom: 10px; +} + +.created { + font-size: 16px; + color: #888; +} + +.loader, +.error { + text-align: center; + margin-top: 40px; + font-size: 18px; +} diff --git a/modules/frontend/src/components/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx similarity index 88% rename from modules/frontend/src/components/UserPage/UserPage.tsx rename to modules/frontend/src/pages/UserPage/UserPage.tsx index 0a83679..52c5574 100644 --- a/modules/frontend/src/components/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -33,8 +33,8 @@ const UserPage: React.FC = () => { return ( <div className={styles.container}> - <div className={styles.card}> - <div className={styles.avatar}> + <div className={styles.header}> + <div className={styles.avatarWrapper}> {user.avatar_id ? ( <img src={`/images/${user.avatar_id}.png`} @@ -48,13 +48,16 @@ const UserPage: React.FC = () => { )} </div> - <div className={styles.info}> + <div className={styles.userInfo}> <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> <p className={styles.nickname}>@{user.nickname}</p> - {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} - <p className={styles.created}> + {/* <p className={styles.created}> Joined: {new Date(user.creation_date).toLocaleDateString()} - </p> + </p> */} + </div> + + <div className={styles.content}> + {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} </div> </div> </div> diff --git a/modules/frontend/src/types/list.ts b/modules/frontend/src/types/list.ts new file mode 100644 index 0000000..582da39 --- /dev/null +++ b/modules/frontend/src/types/list.ts @@ -0,0 +1,12 @@ +export interface PaginatedResult<TItem> { + items: TItem[]; + nextCursor?: string; +} + +export interface FetchParams { + search: string; + cursor?: string; +} + +export type FetchFunction<TItem> = + (params: FetchParams) => Promise<PaginatedResult<TItem>>; From 7e41b6b9ce4a961e0d37e10a1d6535572226c17c Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 05:26:01 +0300 Subject: [PATCH 35/97] fix --- modules/frontend/tsconfig.node.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/tsconfig.node.json b/modules/frontend/tsconfig.node.json index 8a67f62..3439137 100644 --- a/modules/frontend/tsconfig.node.json +++ b/modules/frontend/tsconfig.node.json @@ -18,7 +18,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, From ecccc29aa8a09fa867760145e281dff3c0b64566 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 05:34:42 +0300 Subject: [PATCH 36/97] fix --- modules/frontend/tsconfig.app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/tsconfig.app.json b/modules/frontend/tsconfig.app.json index a9b5a59..2f416e5 100644 --- a/modules/frontend/tsconfig.app.json +++ b/modules/frontend/tsconfig.app.json @@ -20,7 +20,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, From 8504746d2797c00912a869f1348c578bb3d906d5 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 05:39:11 +0300 Subject: [PATCH 37/97] fix: updated package.json --- modules/frontend/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/frontend/package.json b/modules/frontend/package.json index b4977aa..f15ffd5 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -29,5 +29,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } } From a9391c18b90de18b09df346b814233fa858efb4b Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 15:30:11 +0300 Subject: [PATCH 38/97] fix: TitlesPage import path --- modules/frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 1256086..f67c37e 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; -import TitlesPage from "./pages/UserPage/UserPage"; +import TitlesPage from "./pages/TitlesPage/TitlesPage"; const App: React.FC = () => { return ( From c0be58ba0775a1eda624c14bad63ca8ace41cc03 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 15:39:24 +0300 Subject: [PATCH 39/97] feat: use pgxpool in backend --- go.mod | 5 +++-- modules/backend/main.go | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 7c34aeb..80a9ab1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/crypto v0.40.0 + github.com/sirupsen/logrus v1.9.3 ) require ( @@ -26,6 +26,7 @@ require ( github.com/google/uuid v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -34,11 +35,11 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/modules/backend/main.go b/modules/backend/main.go index 42a66d3..3ac6603 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -13,7 +13,7 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/pelletier/go-toml/v2" ) @@ -31,17 +31,17 @@ func main() { // log.Fatalf("Failed to init config: %v\n", err) // } - conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL")) + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) } - defer conn.Close(context.Background()) + defer pool.Close() r := gin.Default() - queries := sqlc.New(conn) + queries := sqlc.New(pool) server := handlers.NewServer(queries) // r.LoadHTMLGlob("templates/*") From cdfa14cece9584ad059c10dbff061d4a00028098 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 00:41:54 +0300 Subject: [PATCH 40/97] feat: cursor added --- api/api.gen.go | 106 ++++++++++++++++++++++++++++++++++++++++------- api/openapi.yaml | 56 +++++++++++++++++++++---- 2 files changed, 139 insertions(+), 23 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 74a2d52..427f5af 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -24,6 +24,14 @@ const ( Winter ReleaseSeason = "winter" ) +// Defines values for TitleSort. +const ( + Id TitleSort = "id" + Rating TitleSort = "rating" + Views TitleSort = "views" + Year TitleSort = "year" +) + // Defines values for TitleStatus. const ( TitleStatusFinished TitleStatus = "finished" @@ -91,6 +99,9 @@ type Title struct { AdditionalProperties map[string]interface{} `json:"-"` } +// TitleSort Title sort order +type TitleSort string + // TitleStatus Title status type TitleStatus string @@ -139,6 +150,9 @@ type Cursor = string // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` Word *string `form:"word,omitempty" json:"word,omitempty"` Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` @@ -161,11 +175,15 @@ type GetUsersUserIdParams struct { // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } // Getter for additional properties for Title. Returns the specified @@ -580,6 +598,30 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { // Parameter object where we will unmarshal all parameters from the context var params GetTitlesParams + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) @@ -749,11 +791,51 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } - // ------------- Optional query parameter "query" ------------- + // ------------- Optional query parameter "word" ------------- - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "watch_status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter watch_status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) return } @@ -765,14 +847,6 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - // ------------- Optional query parameter "fields" ------------- err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) diff --git a/api/openapi.yaml b/api/openapi.yaml index a33fe89..a6ae12c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -11,6 +11,13 @@ paths: get: summary: Get titles parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - in: query + name: sort_forward + default: true + schema: + type: boolean - in: query name: word schema: @@ -289,19 +296,37 @@ paths: schema: type: string - in: query - name: query + name: word schema: type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: watch_status + schema: + $ref: '#/components/schemas/UserTitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' - in: query name: limit schema: type: integer + format: int32 default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - in: query name: fields schema: @@ -577,14 +602,31 @@ paths: components: parameters: - cursor: + cursor: # example base64( {id: 1, param: 2019}) in: query name: cursor required: false schema: type: string + + title_sort: + in: query + name: sort + required: false + schema: + $ref: '#/components/schemas/TitleSort' schemas: + TitleSort: + type: string + description: Title sort order + default: id + enum: + - id + - year + - rating + - views + Image: type: object properties: From 9ed09500ed773b53c63afcb217e236a2764095f7 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Wed, 19 Nov 2025 01:42:40 +0300 Subject: [PATCH 41/97] refact: slightly refactored openapi spec --- api/openapi.yaml | 438 +++-------------------------------------------- 1 file changed, 26 insertions(+), 412 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a6ae12c..b52ba58 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.1 +openapi: 3.0.4 info: title: Titles, Users, Reviews, Tags, and Media API version: 1.0.0 @@ -15,9 +15,9 @@ paths: - $ref: '#/components/parameters/title_sort' - in: query name: sort_forward - default: true schema: type: boolean + default: true - in: query name: word schema: @@ -59,13 +59,22 @@ paths: default: all responses: '200': - description: List of titles + description: List of titles with cursor content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Title' + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Title' + description: List of titles + cursor: + $ref: "#/components/schemas/CursorObj" + required: + - data + - cursor '204': description: No titles found '400': @@ -104,66 +113,6 @@ paths: '204': description: No title found -# patch: -# summary: Update title info -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Title' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# user_json: -# $ref: '#/components/schemas/User' - -# /titles/{title_id}/reviews: -# get: -# summary: Get title reviews -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# responses: -# '200': -# description: List of reviews -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Review' -# '204': -# description: No reviews found - /users/{user_id}: get: summary: Get user info @@ -192,99 +141,6 @@ paths: '500': description: Unknown server error - # patch: - # summary: Update user - # parameters: - # - in: path - # name: user_id - # required: true - # schema: - # type: string - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Update result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - - # delete: - # summary: Delete user - # parameters: - # - in: path - # name: user_id - # required: true - # schema: - # type: string - # responses: - # '200': - # description: Delete result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - - # /users: - # get: - # summary: Search user - # parameters: - # - in: query - # name: query - # schema: - # type: string - # - in: query - # name: fields - # schema: - # type: string - # responses: - # '200': - # description: List of users - # content: - # application/json: - # schema: - # type: array - # items: - # $ref: '#/components/schemas/User' - - # post: - # summary: Add new user - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Add result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - # user_json: - # $ref: '#/components/schemas/User' - /users/{user_id}/titles/: get: summary: Get user titles @@ -348,258 +204,6 @@ paths: '500': description: Unknown server error -# post: -# summary: Add user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# type: object -# properties: -# title_id: -# type: string -# status: -# type: string -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# patch: -# summary: Update user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/UserTitle' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# delete: -# summary: Delete user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: title_id -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /users/{user_id}/reviews: -# get: -# summary: Get user reviews -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# responses: -# '200': -# description: List of reviews -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Review' - -# /reviews: -# post: -# summary: Add review -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Review' -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /reviews/{review_id}: -# patch: -# summary: Update review -# parameters: -# - in: path -# name: review_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Review' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# delete: -# summary: Delete review -# parameters: -# - in: path -# name: review_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /tags: -# get: -# summary: Get tags -# parameters: -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# - in: query -# name: fields -# schema: -# type: string -# responses: -# '200': -# description: List of tags -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Tag' - -# /media: -# post: -# summary: Upload image -# responses: -# '200': -# description: Upload result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# image_id: -# type: string - -# get: -# summary: Get image path -# parameters: -# - in: query -# name: image_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Image path -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# image_path: -# type: string - components: parameters: cursor: # example base64( {id: 1, param: 2019}) @@ -617,6 +221,17 @@ components: $ref: '#/components/schemas/TitleSort' schemas: + CursorObj: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + param: + type: string + TitleSort: type: string description: Title sort order @@ -778,7 +393,6 @@ components: type: integer format: int64 description: ID of the user avatar (references images table) - nullable: true example: null mail: type: string From 2025bb451fbde55c50f235117674aaea1ba751ee Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 03:14:41 +0300 Subject: [PATCH 42/97] refact: openapi splitted into separate files --- api/openapi.yaml | 441 +------------------------ api/parameters/_index.yaml | 4 + api/parameters/cursor.yaml | 5 + api/parameters/title_sort.yaml | 5 + api/paths/titles-id.yaml | 29 ++ api/paths/titles.yaml | 73 ++++ api/paths/users-id-titles.yaml | 61 ++++ api/paths/users-id.yaml | 26 ++ api/schemas/CursorObj.yaml | 9 + api/schemas/Image.yaml | 9 + api/schemas/Review.yaml | 2 + api/schemas/Studio.yaml | 14 + api/schemas/Tag.yaml | 8 + api/schemas/Tags.yaml | 11 + api/schemas/Title.yaml | 63 ++++ api/schemas/TitleSort.yaml | 8 + api/schemas/User.yaml | 40 +++ api/schemas/UserTitle.yaml | 24 ++ api/schemas/_index.yaml | 26 ++ api/schemas/enums/ReleaseSeason.yaml | 7 + api/schemas/enums/TitleStatus.yaml | 6 + api/schemas/enums/UserTitleStatus.yaml | 7 + 22 files changed, 443 insertions(+), 435 deletions(-) create mode 100644 api/parameters/_index.yaml create mode 100644 api/parameters/cursor.yaml create mode 100644 api/parameters/title_sort.yaml create mode 100644 api/paths/titles-id.yaml create mode 100644 api/paths/titles.yaml create mode 100644 api/paths/users-id-titles.yaml create mode 100644 api/paths/users-id.yaml create mode 100644 api/schemas/CursorObj.yaml create mode 100644 api/schemas/Image.yaml create mode 100644 api/schemas/Review.yaml create mode 100644 api/schemas/Studio.yaml create mode 100644 api/schemas/Tag.yaml create mode 100644 api/schemas/Tags.yaml create mode 100644 api/schemas/Title.yaml create mode 100644 api/schemas/TitleSort.yaml create mode 100644 api/schemas/User.yaml create mode 100644 api/schemas/UserTitle.yaml create mode 100644 api/schemas/_index.yaml create mode 100644 api/schemas/enums/ReleaseSeason.yaml create mode 100644 api/schemas/enums/TitleStatus.yaml create mode 100644 api/schemas/enums/UserTitleStatus.yaml diff --git a/api/openapi.yaml b/api/openapi.yaml index b52ba58..281fe82 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -8,444 +8,15 @@ servers: paths: /titles: - get: - summary: Get titles - parameters: - - $ref: '#/components/parameters/cursor' - - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward - schema: - type: boolean - default: true - - in: query - name: word - schema: - type: string - - in: query - name: status - schema: - $ref: '#/components/schemas/TitleStatus' - - in: query - name: rating - schema: - type: number - format: double - - in: query - name: release_year - schema: - type: integer - format: int32 - - in: query - name: release_season - schema: - $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit - schema: - type: integer - format: int32 - default: 10 - - in: query - name: offset - schema: - type: integer - format: int32 - default: 0 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: List of titles with cursor - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/Title' - description: List of titles - cursor: - $ref: "#/components/schemas/CursorObj" - required: - - data - - cursor - '204': - description: No titles found - '400': - description: Request params are not correct - '500': - description: Unknown server error - + $ref: "./paths/titles.yaml" /titles/{title_id}: - get: - summary: Get title description - parameters: - - in: path - name: title_id - required: true - schema: - type: integer - format: int64 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: Title description - content: - application/json: - schema: - $ref: '#/components/schemas/Title' - '404': - description: Title not found - '400': - description: Request params are not correct - '500': - description: Unknown server error - '204': - description: No title found - + $ref: "./paths/titles-id.yaml" /users/{user_id}: - get: - summary: Get user info - parameters: - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: User info - content: - application/json: - schema: - $ref: '#/components/schemas/User' - '404': - description: User not found - '400': - description: Request params are not correct - '500': - description: Unknown server error - + $ref: "./paths/users-id.yaml" /users/{user_id}/titles/: - get: - summary: Get user titles - parameters: - - $ref: '#/components/parameters/cursor' - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: word - schema: - type: string - - in: query - name: status - schema: - $ref: '#/components/schemas/TitleStatus' - - in: query - name: watch_status - schema: - $ref: '#/components/schemas/UserTitleStatus' - - in: query - name: rating - schema: - type: number - format: double - - in: query - name: release_year - schema: - type: integer - format: int32 - - in: query - name: release_season - schema: - $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit - schema: - type: integer - format: int32 - default: 10 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: List of user titles - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/UserTitle' - '204': - description: No titles found - '400': - description: Request params are not correct - '500': - description: Unknown server error - + $ref: "./paths/users-id-titles.yaml" components: parameters: - cursor: # example base64( {id: 1, param: 2019}) - in: query - name: cursor - required: false - schema: - type: string - - title_sort: - in: query - name: sort - required: false - schema: - $ref: '#/components/schemas/TitleSort' - + $ref: "./parameters/_index.yaml" schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string - - TitleSort: - type: string - description: Title sort order - default: id - enum: - - id - - year - - rating - - views - - Image: - type: object - properties: - id: - type: integer - format: int64 - storage_type: - type: string - image_path: - type: string - - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - - Review: - type: object - additionalProperties: true - - Tag: - type: object - description: "A localized tag: keys are language codes (ISO 639-1), values are tag names" - additionalProperties: - type: string - example: - en: "Shojo" - ru: "Сёдзё" - ja: "少女" - - Tags: - type: array - description: "Array of localized tags" - items: - $ref: '#/components/schemas/Tag' - example: - - en: "Shojo" - ru: "Сёдзё" - ja: "少女" - - en: "Shounen" - ru: "Сёнен" - ja: "少年" - - Studio: - type: object - required: - - id - - name - properties: - id: - type: integer - format: int64 - name: - type: string - poster: - $ref: '#/components/schemas/Image' - description: - type: string - - - Title: - type: object - required: - - id - - title_names - - tags - properties: - id: - type: integer - format: int64 - description: Unique title ID (primary key) - example: 1 - title_names: - type: object - description: "Localized titles. Key = language (ISO 639-1), value = list of names" - additionalProperties: - type: array - items: - type: string - example: "Attack on Titan" - minItems: 1 - example: ["Attack on Titan", "AoT"] - example: - en: ["Attack on Titan", "AoT"] - ru: ["Атака титанов", "Титаны"] - ja: ["進撃の巨人"] - studio: - $ref: '#/components/schemas/Studio' - tags: - $ref: '#/components/schemas/Tags' - poster: - $ref: '#/components/schemas/Image' - title_status: - $ref: '#/components/schemas/TitleStatus' - rating: - type: number - format: double - rating_count: - type: integer - format: int32 - release_year: - type: integer - format: int32 - release_season: - $ref: '#/components/schemas/ReleaseSeason' - episodes_aired: - type: integer - format: int32 - episodes_all: - type: integer - format: int32 - episodes_len: - type: object - additionalProperties: - type: number - format: double - additionalProperties: true - - User: - type: object - properties: - id: - type: integer - format: int64 - description: Unique user ID (primary key) - example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null - mail: - type: string - format: email - description: User email - example: "john.doe@example.com" - nickname: - type: string - description: Username (alphanumeric + _ or -) - maxLength: 16 - example: "john_doe_42" - disp_name: - type: string - description: Display name - maxLength: 32 - example: "John Doe" - user_desc: - type: string - description: User description - maxLength: 512 - example: "Just a regular user." - creation_date: - type: string - format: date-time - description: Timestamp when the user was created - example: "2025-10-10T23:45:47.908073Z" - required: - - user_id - - nickname - # - creation_date - - UserTitle: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: true + $ref: "./schemas/_index.yaml" diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml new file mode 100644 index 0000000..6249e7d --- /dev/null +++ b/api/parameters/_index.yaml @@ -0,0 +1,4 @@ +cursor: + $ref: "./cursor.yaml" +title_sort: + $ref: "./title_sort.yaml" \ No newline at end of file diff --git a/api/parameters/cursor.yaml b/api/parameters/cursor.yaml new file mode 100644 index 0000000..1730f18 --- /dev/null +++ b/api/parameters/cursor.yaml @@ -0,0 +1,5 @@ +in: query +name: cursor +required: false +schema: + type: string diff --git a/api/parameters/title_sort.yaml b/api/parameters/title_sort.yaml new file mode 100644 index 0000000..615294b --- /dev/null +++ b/api/parameters/title_sort.yaml @@ -0,0 +1,5 @@ +in: query +name: sort +required: false +schema: + $ref: '../schemas/TitleSort.yaml' diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml new file mode 100644 index 0000000..01fa504 --- /dev/null +++ b/api/paths/titles-id.yaml @@ -0,0 +1,29 @@ +get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: "../schemas/Title.yaml" + '404': + description: Title not found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '204': + description: No title found diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml new file mode 100644 index 0000000..4fd010d --- /dev/null +++ b/api/paths/titles.yaml @@ -0,0 +1,73 @@ +get: + summary: Get titles + parameters: + - $ref: ../parameters/cursor.yaml + - $ref: ../parameters/title_sort.yaml + - in: query + name: sort_forward + schema: + type: boolean + default: true + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '../schemas/enums/TitleStatus.yaml' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '../schemas/enums/ReleaseSeason.yaml' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: offset + schema: + type: integer + format: int32 + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../schemas/Title.yaml' + description: List of titles + cursor: + $ref: '../schemas/CursorObj.yaml' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml new file mode 100644 index 0000000..0cde5af --- /dev/null +++ b/api/paths/users-id-titles.yaml @@ -0,0 +1,61 @@ +get: + summary: Get user titles + parameters: + - $ref: '../parameters/cursor.yaml' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '../schemas/enums/TitleStatus.yaml' + - in: query + name: watch_status + schema: + $ref: '../schemas/enums/UserTitleStatus.yaml' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '../schemas/enums/ReleaseSeason.yaml' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '../schemas/UserTitle.yaml' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml new file mode 100644 index 0000000..0acdb81 --- /dev/null +++ b/api/paths/users-id.yaml @@ -0,0 +1,26 @@ +get: + summary: Get user info + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '404': + description: User not found + '400': + description: Request params are not correct + '500': + description: Unknown server error diff --git a/api/schemas/CursorObj.yaml b/api/schemas/CursorObj.yaml new file mode 100644 index 0000000..5a3c96c --- /dev/null +++ b/api/schemas/CursorObj.yaml @@ -0,0 +1,9 @@ +type: object +required: + - id +properties: + id: + type: integer + format: int64 + param: + type: string diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml new file mode 100644 index 0000000..7226b29 --- /dev/null +++ b/api/schemas/Image.yaml @@ -0,0 +1,9 @@ +type: object +properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string diff --git a/api/schemas/Review.yaml b/api/schemas/Review.yaml new file mode 100644 index 0000000..a116dde --- /dev/null +++ b/api/schemas/Review.yaml @@ -0,0 +1,2 @@ +type: object +additionalProperties: true diff --git a/api/schemas/Studio.yaml b/api/schemas/Studio.yaml new file mode 100644 index 0000000..35b40a8 --- /dev/null +++ b/api/schemas/Studio.yaml @@ -0,0 +1,14 @@ +type: object +required: + - id + - name +properties: + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: ./Image.yaml + description: + type: string diff --git a/api/schemas/Tag.yaml b/api/schemas/Tag.yaml new file mode 100644 index 0000000..7239b10 --- /dev/null +++ b/api/schemas/Tag.yaml @@ -0,0 +1,8 @@ +type: object +description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' +additionalProperties: + type: string +example: + en: Shojo + ru: Сёдзё + ja: 少女 diff --git a/api/schemas/Tags.yaml b/api/schemas/Tags.yaml new file mode 100644 index 0000000..ca8c4fd --- /dev/null +++ b/api/schemas/Tags.yaml @@ -0,0 +1,11 @@ +type: array +description: Array of localized tags +items: + $ref: ./Tag.yaml +example: +- en: Shojo + ru: Сёдзё + ja: 少女 +- en: Shounen + ru: Сёнен + ja: 少年 diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml new file mode 100644 index 0000000..7497d1f --- /dev/null +++ b/api/schemas/Title.yaml @@ -0,0 +1,63 @@ +type: object +required: + - id + - title_names + - tags +properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: object + description: Localized titles. Key = language (ISO 639-1), value = list of names + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT + example: + en: + - Attack on Titan + - AoT + ru: + - Атака титанов + - Титаны + ja: + - 進撃の巨人 + studio: + $ref: ./Studio.yaml + tags: + $ref: ./Tags.yaml + poster: + $ref: ./Image.yaml + title_status: + $ref: ./enums/TitleStatus.yaml + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: ./enums/ReleaseSeason.yaml + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double +additionalProperties: true diff --git a/api/schemas/TitleSort.yaml b/api/schemas/TitleSort.yaml new file mode 100644 index 0000000..d8ce8f7 --- /dev/null +++ b/api/schemas/TitleSort.yaml @@ -0,0 +1,8 @@ +type: string +description: Title sort order +default: id +enum: + - id + - year + - rating + - views diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml new file mode 100644 index 0000000..8b4d88d --- /dev/null +++ b/api/schemas/User.yaml @@ -0,0 +1,40 @@ +type: object +properties: + id: + type: integer + format: int64 + description: Unique user ID (primary key) + example: 1 + avatar_id: + type: integer + format: int64 + description: ID of the user avatar (references images table) + example: null + mail: + type: string + format: email + description: User email + example: john.doe@example.com + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: john_doe_42 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Doe + user_desc: + type: string + description: User description + maxLength: 512 + example: Just a regular user. + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: '2025-10-10T23:45:47.908073Z' +required: + - user_id + - nickname diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml new file mode 100644 index 0000000..658e350 --- /dev/null +++ b/api/schemas/UserTitle.yaml @@ -0,0 +1,24 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time +additionalProperties: true diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml new file mode 100644 index 0000000..ac49f37 --- /dev/null +++ b/api/schemas/_index.yaml @@ -0,0 +1,26 @@ +CursorObj: + $ref: ./CursorObj.yaml +TitleSort: + $ref: "./TitleSort.yaml" +Image: + $ref: "./Image.yaml" +TitleStatus: + $ref: "./enums/TitleStatus.yaml" +ReleaseSeason: + $ref: "./enums/ReleaseSeason.yaml" +UserTitleStatus: + $ref: "./enums/UserTitleStatus.yaml" +Review: + $ref: "./Review.yaml" +Tag: + $ref: "./Tag.yaml" +Tags: + $ref: "./Tags.yaml" +Studio: + $ref: "./Studio.yaml" +Title: + $ref: "./Title.yaml" +User: + $ref: "./User.yaml" +UserTitle: + $ref: "./UserTitle.yaml" diff --git a/api/schemas/enums/ReleaseSeason.yaml b/api/schemas/enums/ReleaseSeason.yaml new file mode 100644 index 0000000..5cf988d --- /dev/null +++ b/api/schemas/enums/ReleaseSeason.yaml @@ -0,0 +1,7 @@ +type: string +description: Title release season +enum: + - winter + - spring + - summer + - fall diff --git a/api/schemas/enums/TitleStatus.yaml b/api/schemas/enums/TitleStatus.yaml new file mode 100644 index 0000000..0bfce7d --- /dev/null +++ b/api/schemas/enums/TitleStatus.yaml @@ -0,0 +1,6 @@ +type: string +description: Title status +enum: + - finished + - ongoing + - planned diff --git a/api/schemas/enums/UserTitleStatus.yaml b/api/schemas/enums/UserTitleStatus.yaml new file mode 100644 index 0000000..0b1a90d --- /dev/null +++ b/api/schemas/enums/UserTitleStatus.yaml @@ -0,0 +1,7 @@ +type: string +description: User's title status +enum: + - finished + - planned + - dropped + - in-progress From fbf3f1d3a28fa45f0698aece2e824f7568de1e8b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 03:57:44 +0300 Subject: [PATCH 43/97] feat: now use _build to build --- api/_build/oapi-codegen.yaml | 6 + api/_build/openapi.yaml | 436 +++++++++++++++++++++++++++++++++++ api/api.gen.go | 15 +- api/paths/titles.yaml | 4 +- 4 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 api/_build/oapi-codegen.yaml create mode 100644 api/_build/openapi.yaml diff --git a/api/_build/oapi-codegen.yaml b/api/_build/oapi-codegen.yaml new file mode 100644 index 0000000..32e029a --- /dev/null +++ b/api/_build/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: oapi +generate: + strict-server: true + gin-server: true + models: true +output: api/api.gen.go \ No newline at end of file diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml new file mode 100644 index 0000000..5ff77e0 --- /dev/null +++ b/api/_build/openapi.yaml @@ -0,0 +1,436 @@ +openapi: 3.0.4 +info: + title: 'Titles, Users, Reviews, Tags, and Media API' + version: 1.0.0 +servers: + - url: /api/v1 +paths: + /titles: + get: + summary: Get titles + parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - in: query + name: sort_forward + schema: + type: boolean + default: true + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: offset + schema: + type: integer + format: int32 + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Title' + description: List of titles + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '/titles/{title_id}': + get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: '#/components/schemas/Title' + '204': + description: No title found + '400': + description: Request params are not correct + '404': + description: Title not found + '500': + description: Unknown server error + '/users/{user_id}': + get: + summary: Get user info + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Request params are not correct + '404': + description: User not found + '500': + description: Unknown server error + '/users/{user_id}/titles/': + get: + summary: Get user titles + parameters: + - $ref: '#/components/parameters/cursor' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: watch_status + schema: + $ref: '#/components/schemas/UserTitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserTitle' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error +components: + parameters: + cursor: + in: query + name: cursor + required: false + schema: + type: string + title_sort: + in: query + name: sort + required: false + schema: + $ref: '#/components/schemas/TitleSort' + schemas: + CursorObj: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + param: + type: string + TitleSort: + type: string + description: Title sort order + default: id + enum: + - id + - year + - rating + - views + Image: + type: object + properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string + TitleStatus: + type: string + description: Title status + enum: + - finished + - ongoing + - planned + ReleaseSeason: + type: string + description: Title release season + enum: + - winter + - spring + - summer + - fall + UserTitleStatus: + type: string + description: User's title status + enum: + - finished + - planned + - dropped + - in-progress + Review: + type: object + additionalProperties: true + Tag: + type: object + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + additionalProperties: + type: string + example: + en: Shojo + ru: Сёдзё + ja: 少女 + Tags: + type: array + description: Array of localized tags + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Studio: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: '#/components/schemas/Image' + description: + type: string + Title: + type: object + required: + - id + - title_names + - tags + properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: object + description: 'Localized titles. Key = language (ISO 639-1), value = list of names' + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT + example: + en: + - Attack on Titan + - AoT + ru: + - Атака титанов + - Титаны + ja: + - 進撃の巨人 + studio: + $ref: '#/components/schemas/Studio' + tags: + $ref: '#/components/schemas/Tags' + poster: + $ref: '#/components/schemas/Image' + title_status: + $ref: '#/components/schemas/TitleStatus' + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: '#/components/schemas/ReleaseSeason' + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double + additionalProperties: true + User: + type: object + properties: + id: + type: integer + format: int64 + description: Unique user ID (primary key) + example: 1 + avatar_id: + type: integer + format: int64 + description: ID of the user avatar (references images table) + example: null + mail: + type: string + format: email + description: User email + example: john.doe@example.com + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: john_doe_42 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Doe + user_desc: + type: string + description: User description + maxLength: 512 + example: Just a regular user. + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: '2025-10-10T23:45:47.908073Z' + required: + - user_id + - nickname + UserTitle: + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index 427f5af..f252a5a 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -47,6 +47,12 @@ const ( UserTitleStatusPlanned UserTitleStatus = "planned" ) +// CursorObj defines model for CursorObj. +type CursorObj struct { + Id int64 `json:"id"` + Param *string `json:"param,omitempty"` +} + // Image defines model for Image. type Image struct { Id *int64 `json:"id,omitempty"` @@ -108,7 +114,7 @@ type TitleStatus string // User defines model for User. type User struct { // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id"` + AvatarId *int64 `json:"avatar_id,omitempty"` // CreationDate Timestamp when the user was created CreationDate *time.Time `json:"creation_date,omitempty"` @@ -906,7 +912,12 @@ type GetTitlesResponseObject interface { VisitGetTitlesResponse(w http.ResponseWriter) error } -type GetTitles200JSONResponse []Title +type GetTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + + // Data List of titles + Data []Title `json:"data"` +} func (response GetTitles200JSONResponse) VisitGetTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index 4fd010d..e868ed6 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -1,8 +1,8 @@ get: summary: Get titles parameters: - - $ref: ../parameters/cursor.yaml - - $ref: ../parameters/title_sort.yaml + - $ref: "../parameters/cursor.yaml" + - $ref: "../parameters/title_sort.yaml" - in: query name: sort_forward schema: From 34d9341e75dbdf36ebbf9258f82e5065fc38909c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 03:58:46 +0300 Subject: [PATCH 44/97] feat: cursor stub added --- modules/backend/handlers/titles.go | 6 +- modules/backend/queries.sql | 62 +++++++++++- sql/migrations/000001_init.up.sql | 2 +- sql/models.go | 13 ++- sql/queries.sql.go | 151 +++++++++++++++++++++++++++-- 5 files changed, 214 insertions(+), 20 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 46ff982..e8a3bff 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -218,6 +218,9 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) + cursor := oapi.CursorObj{ + Id: 1, + } word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -237,7 +240,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, - Offset: request.Params.Offset, Limit: request.Params.Limit, }) if err != nil { @@ -258,5 +260,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles = append(opai_titles, t) } - return oapi.GetTitles200JSONResponse(opai_titles), nil + return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 423be37..8962895 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -107,9 +107,67 @@ WHERE AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) +ORDER BY + -- Основной ключ: выбранное поле + CASE + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + END ASC, + CASE + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + END DESC, -LIMIT COALESCE(sqlc.narg('limit')::int, 100) -- 100 is default limit -OFFSET sqlc.narg('offset')::int; + -- Вторичный ключ: id — только если НЕ сортируем по id + CASE + WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id + END ASC, + CASE + WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id + END DESC +LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit +-- OFFSET sqlc.narg('offset')::int; + +-- name: SearchUserTitles :many +SELECT + * +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +WHERE + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t) + AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t) + +LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- -- name: ListTitles :many -- SELECT title_id, title_names, studio_id, poster_id, signal_ids, diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 49cca3d..e6ed628 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -59,7 +59,7 @@ CREATE TABLE studios ( ); CREATE TABLE titles ( - // TODO: anime type (film, season etc) + -- // TODO: anime type (film, season etc) id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, diff --git a/sql/models.go b/sql/models.go index a593504..93cecca 100644 --- a/sql/models.go +++ b/sql/models.go @@ -212,7 +212,6 @@ type Review struct { ID int64 `json:"id"` Data string `json:"data"` Rating *int32 `json:"rating"` - IllustID *int64 `json:"illust_id"` UserID *int64 `json:"user_id"` TitleID *int64 `json:"title_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` @@ -277,10 +276,10 @@ type User struct { } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewText *string `json:"review_text"` - ReviewDate pgtype.Timestamptz `json:"review_date"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index c5e6f8a..4e28f40 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -8,6 +8,8 @@ package sqlc import ( "context" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -31,7 +33,7 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = $1 +WHERE id = $1::bigint ` func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, error) { @@ -44,11 +46,13 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro const getReviewByID = `-- name: GetReviewByID :one -SELECT id, data, rating, illust_id, user_id, title_id, created_at + +SELECT id, data, rating, user_id, title_id, created_at FROM reviews WHERE review_id = $1::bigint ` +// 100 is default limit // -- name: ListTitles :many // SELECT title_id, title_names, studio_id, poster_id, signal_ids, // @@ -82,7 +86,6 @@ func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, er &i.ID, &i.Data, &i.Rating, - &i.IllustID, &i.UserID, &i.TitleID, &i.CreatedAt, @@ -312,9 +315,18 @@ WHERE AND ($3::float IS NULL OR rating >= $3::float) AND ($4::int IS NULL OR release_year = $4::int) AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) - -LIMIT COALESCE($7::int, 100) -- 100 is default limit -OFFSET $6::int +ORDER BY CASE + WHEN $6::boolean AND $7::text = 'name' THEN name + WHEN $8::boolean AND $7::text = 'id' THEN id + WHEN $8::boolean AND $7::text = 'name' THEN name + WHEN $8::boolean AND $7::text = 'id' THEN id +END ASC, CASE + WHEN NOT $8::boolean AND $7::text = 'name' THEN name + WHEN NOT $8::boolean AND $7::text = 'id' THEN id + WHEN NOT $8::boolean AND $7::text = 'name' THEN name + WHEN NOT $8::boolean AND $7::text = 'id' THEN id +END DESC +LIMIT COALESCE($9::int, 100) ` type SearchTitlesParams struct { @@ -323,7 +335,9 @@ type SearchTitlesParams struct { Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Offset *int32 `json:"offset"` + Forward bool `json:"forward"` + OrderBy string `json:"order_by"` + Reverse bool `json:"reverse"` Limit *int32 `json:"limit"` } @@ -334,7 +348,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.Offset, + arg.Forward, + arg.OrderBy, + arg.Reverse, arg.Limit, ) if err != nil { @@ -368,3 +384,122 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T } return items, nil } + +const searchUserTitles = `-- name: SearchUserTitles :many + +SELECT + user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) + AND ($3::float IS NULL OR t.rating >= $3::float) + AND ($4::int IS NULL OR t.release_year = $4::int) + AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) + AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) + +LIMIT COALESCE($7::int, 100) +` + +type SearchUserTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` + Limit *int32 `json:"limit"` +} + +type SearchUserTitlesRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` +} + +// 100 is default limit +// OFFSET sqlc.narg('offset')::int; +func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { + rows, err := q.db.Query(ctx, searchUserTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.UsertitleStatus, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchUserTitlesRow + for rows.Next() { + var i SearchUserTitlesRow + if err := rows.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} From e2ac80610cb0a0de71d7775dd97571c67fdd0f08 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 04:11:31 +0300 Subject: [PATCH 45/97] fix: now gettitles must work --- modules/backend/handlers/titles.go | 2 ++ modules/backend/queries.sql | 2 -- sql/queries.sql.go | 41 ++++++++++++++++++------------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index e8a3bff..f187cc4 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -240,6 +240,8 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, + Forward: true, + SortBy: "id", Limit: request.Params.Limit, }) if err != nil { diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 8962895..9da104f 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -111,14 +111,12 @@ ORDER BY -- Основной ключ: выбранное поле CASE WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END ASC, CASE WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 4e28f40..14a3f8c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -315,18 +315,29 @@ WHERE AND ($3::float IS NULL OR rating >= $3::float) AND ($4::int IS NULL OR release_year = $4::int) AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) -ORDER BY CASE - WHEN $6::boolean AND $7::text = 'name' THEN name - WHEN $8::boolean AND $7::text = 'id' THEN id - WHEN $8::boolean AND $7::text = 'name' THEN name - WHEN $8::boolean AND $7::text = 'id' THEN id -END ASC, CASE - WHEN NOT $8::boolean AND $7::text = 'name' THEN name - WHEN NOT $8::boolean AND $7::text = 'id' THEN id - WHEN NOT $8::boolean AND $7::text = 'name' THEN name - WHEN NOT $8::boolean AND $7::text = 'id' THEN id -END DESC -LIMIT COALESCE($9::int, 100) +ORDER BY + -- Основной ключ: выбранное поле + CASE + WHEN $6::boolean AND $7::text = 'id' THEN id + WHEN $6::boolean AND $7::text = 'year' THEN release_year + WHEN $6::boolean AND $7::text = 'rating' THEN rating + WHEN $6::boolean AND $7::text = 'views' THEN views + END ASC, + CASE + WHEN NOT $6::boolean AND $7::text = 'id' THEN id + WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year + WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating + WHEN NOT $6::boolean AND $7::text = 'views' THEN views + END DESC, + + -- Вторичный ключ: id — только если НЕ сортируем по id + CASE + WHEN $7::text != 'id' AND $6::boolean THEN id + END ASC, + CASE + WHEN $7::text != 'id' AND NOT $6::boolean THEN id + END DESC +LIMIT COALESCE($8::int, 100) ` type SearchTitlesParams struct { @@ -336,8 +347,7 @@ type SearchTitlesParams struct { ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` Forward bool `json:"forward"` - OrderBy string `json:"order_by"` - Reverse bool `json:"reverse"` + SortBy string `json:"sort_by"` Limit *int32 `json:"limit"` } @@ -349,8 +359,7 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T arg.ReleaseYear, arg.ReleaseSeason, arg.Forward, - arg.OrderBy, - arg.Reverse, + arg.SortBy, arg.Limit, ) if err != nil { From 9c0fada00e0ef74e9b482948b51463ff9d1087c6 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 04:16:23 +0300 Subject: [PATCH 46/97] fix: delete views column --- modules/backend/queries.sql | 4 ++-- sql/queries.sql.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 9da104f..c05edff 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -113,13 +113,13 @@ ORDER BY WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END ASC, CASE WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END DESC, -- Вторичный ключ: id — только если НЕ сортируем по id diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 14a3f8c..5a1d13c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -321,13 +321,13 @@ ORDER BY WHEN $6::boolean AND $7::text = 'id' THEN id WHEN $6::boolean AND $7::text = 'year' THEN release_year WHEN $6::boolean AND $7::text = 'rating' THEN rating - WHEN $6::boolean AND $7::text = 'views' THEN views + -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END ASC, CASE WHEN NOT $6::boolean AND $7::text = 'id' THEN id WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating - WHEN NOT $6::boolean AND $7::text = 'views' THEN views + -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END DESC, -- Вторичный ключ: id — только если НЕ сортируем по id From 397d2bcf70c07aad5fc443f7864eea65895ea0b2 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Wed, 19 Nov 2025 10:54:52 +0300 Subject: [PATCH 47/97] feat: /titles page implementation with cursor pagination --- modules/frontend/package-lock.json | 651 ++++++++++++++++-- modules/frontend/package.json | 5 +- modules/frontend/src/App.tsx | 2 +- modules/frontend/src/api/index.ts | 9 +- .../cursor.ts => api/models/CursorObj.ts} | 6 +- .../frontend/src/api/models/ReleaseSeason.ts | 7 +- .../Tags.ts => api/models/TitleSort.ts} | 5 +- .../frontend/src/api/models/TitleStatus.ts | 6 +- modules/frontend/src/api/models/User.ts | 4 +- .../src/api/models/UserTitleStatus.ts | 7 +- .../Title.ts => api/models/title_sort.ts} | 3 +- .../src/api/services/DefaultService.ts | 48 +- modules/frontend/src/api_/core/ApiError.ts | 25 - .../src/api_/core/ApiRequestOptions.ts | 17 - modules/frontend/src/api_/core/ApiResult.ts | 11 - .../src/api_/core/CancelablePromise.ts | 131 ---- modules/frontend/src/api_/core/OpenAPI.ts | 32 - modules/frontend/src/api_/core/request.ts | 323 --------- modules/frontend/src/api_/index.ts | 23 - modules/frontend/src/api_/models/Image.ts | 10 - .../frontend/src/api_/models/ReleaseSeason.ts | 13 - modules/frontend/src/api_/models/Review.ts | 5 - modules/frontend/src/api_/models/Studio.ts | 12 - modules/frontend/src/api_/models/Tag.ts | 8 - .../frontend/src/api_/models/TitleStatus.ts | 12 - modules/frontend/src/api_/models/User.ts | 35 - modules/frontend/src/api_/models/UserTitle.ts | 5 - .../src/api_/models/UserTitleStatus.ts | 13 - .../src/api_/services/DefaultService.ts | 148 ---- .../src/components/ListView/ListView.tsx | 133 ++-- .../src/components/ListView/useListView.tsx | 37 - .../components/cards/TitleCardHorizontal.tsx | 10 +- .../src/components/cards/TitleCardSquare.tsx | 8 +- modules/frontend/src/index.css | 72 +- .../pages/TitlesPage/TitlesPage.module.css | 60 +- .../src/pages/TitlesPage/TitlesPage.tsx | 142 ++-- modules/frontend/vite.config.ts | 6 +- 37 files changed, 797 insertions(+), 1247 deletions(-) rename modules/frontend/src/{api_/models/cursor.ts => api/models/CursorObj.ts} (66%) rename modules/frontend/src/{api_/models/Tags.ts => api/models/TitleSort.ts} (60%) rename modules/frontend/src/{api_/models/Title.ts => api/models/title_sort.ts} (61%) delete mode 100644 modules/frontend/src/api_/core/ApiError.ts delete mode 100644 modules/frontend/src/api_/core/ApiRequestOptions.ts delete mode 100644 modules/frontend/src/api_/core/ApiResult.ts delete mode 100644 modules/frontend/src/api_/core/CancelablePromise.ts delete mode 100644 modules/frontend/src/api_/core/OpenAPI.ts delete mode 100644 modules/frontend/src/api_/core/request.ts delete mode 100644 modules/frontend/src/api_/index.ts delete mode 100644 modules/frontend/src/api_/models/Image.ts delete mode 100644 modules/frontend/src/api_/models/ReleaseSeason.ts delete mode 100644 modules/frontend/src/api_/models/Review.ts delete mode 100644 modules/frontend/src/api_/models/Studio.ts delete mode 100644 modules/frontend/src/api_/models/Tag.ts delete mode 100644 modules/frontend/src/api_/models/TitleStatus.ts delete mode 100644 modules/frontend/src/api_/models/User.ts delete mode 100644 modules/frontend/src/api_/models/UserTitle.ts delete mode 100644 modules/frontend/src/api_/models/UserTitleStatus.ts delete mode 100644 modules/frontend/src/api_/services/DefaultService.ts delete mode 100644 modules/frontend/src/components/ListView/useListView.tsx diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index 6a06afb..f5dde46 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,10 +8,13 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -337,7 +340,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -354,7 +356,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -371,7 +372,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -388,7 +388,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -405,7 +404,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -422,7 +420,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -439,7 +436,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -456,7 +452,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -473,7 +468,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -490,7 +484,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -507,7 +500,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -524,7 +516,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -541,7 +532,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -558,7 +548,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -575,7 +564,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -592,7 +580,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -609,7 +596,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,7 +612,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,7 +628,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -660,7 +644,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -677,7 +660,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -694,7 +676,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -711,7 +692,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,7 +708,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -745,7 +724,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -762,7 +740,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -929,6 +906,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -985,7 +971,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -996,7 +981,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1007,7 +991,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1017,14 +1000,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1090,7 +1071,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1104,7 +1084,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1118,7 +1097,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1132,7 +1110,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1146,7 +1123,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1160,7 +1136,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1174,7 +1149,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1188,7 +1162,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1202,7 +1175,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1216,7 +1188,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1230,7 +1201,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1244,7 +1214,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1258,7 +1227,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1272,7 +1240,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1286,7 +1253,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1300,7 +1266,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1314,7 +1279,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1328,7 +1292,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1342,7 +1305,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1356,7 +1318,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1370,7 +1331,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1384,13 +1344,269 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1440,7 +1656,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1454,7 +1669,7 @@ "version": "24.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2127,6 +2342,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2148,6 +2372,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2197,7 +2434,6 @@ "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2617,7 +2853,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2726,7 +2961,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -2884,6 +3118,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2988,6 +3231,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3021,6 +3513,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3109,7 +3610,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3249,7 +3749,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3269,7 +3768,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3437,7 +3935,6 @@ "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3558,7 +4055,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3590,11 +4086,29 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3611,7 +4125,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3629,7 +4142,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -3735,7 +4247,7 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -3793,7 +4305,6 @@ "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3869,7 +4380,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3887,7 +4397,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { diff --git a/modules/frontend/package.json b/modules/frontend/package.json index b4977aa..beb2b2a 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,10 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 1256086..f67c37e 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; -import TitlesPage from "./pages/UserPage/UserPage"; +import TitlesPage from "./pages/TitlesPage/TitlesPage"; const App: React.FC = () => { return ( diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index f0d09ee..80ae491 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -8,16 +8,19 @@ export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; export type { cursor } from './models/cursor'; +export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; -export { ReleaseSeason } from './models/ReleaseSeason'; +export type { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; export type { Tags } from './models/Tags'; export type { Title } from './models/Title'; -export { TitleStatus } from './models/TitleStatus'; +export type { title_sort } from './models/title_sort'; +export type { TitleSort } from './models/TitleSort'; +export type { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; -export { UserTitleStatus } from './models/UserTitleStatus'; +export type { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api_/models/cursor.ts b/modules/frontend/src/api/models/CursorObj.ts similarity index 66% rename from modules/frontend/src/api_/models/cursor.ts rename to modules/frontend/src/api/models/CursorObj.ts index 5788e14..f54abb1 100644 --- a/modules/frontend/src/api_/models/cursor.ts +++ b/modules/frontend/src/api/models/CursorObj.ts @@ -2,4 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type cursor = string; +export type CursorObj = { + id: number; + param?: string; +}; + diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts index 182b980..ad9f930 100644 --- a/modules/frontend/src/api/models/ReleaseSeason.ts +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -5,9 +5,4 @@ /** * Title release season */ -export enum ReleaseSeason { - WINTER = 'winter', - SPRING = 'spring', - SUMMER = 'summer', - FALL = 'fall', -} +export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; diff --git a/modules/frontend/src/api_/models/Tags.ts b/modules/frontend/src/api/models/TitleSort.ts similarity index 60% rename from modules/frontend/src/api_/models/Tags.ts rename to modules/frontend/src/api/models/TitleSort.ts index 748f066..1c9385e 100644 --- a/modules/frontend/src/api_/models/Tags.ts +++ b/modules/frontend/src/api/models/TitleSort.ts @@ -2,8 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { Tag } from './Tag'; /** - * Array of localized tags + * Title sort order */ -export type Tags = Array<Tag>; +export type TitleSort = 'id' | 'year' | 'rating' | 'views'; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts index 811ece8..72e0261 100644 --- a/modules/frontend/src/api/models/TitleStatus.ts +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -5,8 +5,4 @@ /** * Title status */ -export enum TitleStatus { - FINISHED = 'finished', - ONGOING = 'ongoing', - PLANNED = 'planned', -} +export type TitleStatus = 'finished' | 'ongoing' | 'planned'; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index 541028e..cd76dbe 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -6,11 +6,11 @@ export type User = { /** * Unique user ID (primary key) */ - id: number; + id?: number; /** * ID of the user avatar (references images table) */ - avatar_id?: number | null; + avatar_id?: number; /** * User email */ diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts index 20651fe..0a29626 100644 --- a/modules/frontend/src/api/models/UserTitleStatus.ts +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -5,9 +5,4 @@ /** * User's title status */ -export enum UserTitleStatus { - FINISHED = 'finished', - PLANNED = 'planned', - DROPPED = 'dropped', - IN_PROGRESS = 'in-progress', -} +export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; diff --git a/modules/frontend/src/api_/models/Title.ts b/modules/frontend/src/api/models/title_sort.ts similarity index 61% rename from modules/frontend/src/api_/models/Title.ts rename to modules/frontend/src/api/models/title_sort.ts index 4da7aa3..69b01a7 100644 --- a/modules/frontend/src/api_/models/Title.ts +++ b/modules/frontend/src/api/models/title_sort.ts @@ -2,4 +2,5 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Title = Record<string, any>; +import type { TitleSort } from './TitleSort'; +export type title_sort = TitleSort; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index b0ae54d..52321b8 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -2,17 +2,23 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { CursorObj } from '../models/CursorObj'; import type { ReleaseSeason } from '../models/ReleaseSeason'; import type { Title } from '../models/Title'; +import type { TitleSort } from '../models/TitleSort'; import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class DefaultService { /** * Get titles + * @param cursor + * @param sort + * @param sortForward * @param word * @param status * @param rating @@ -21,10 +27,13 @@ export class DefaultService { * @param limit * @param offset * @param fields - * @returns Title List of titles + * @returns any List of titles with cursor * @throws ApiError */ public static getTitles( + cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, word?: string, status?: TitleStatus, rating?: number, @@ -33,11 +42,20 @@ export class DefaultService { limit: number = 10, offset?: number, fields: string = 'all', - ): CancelablePromise<Array<Title>> { + ): CancelablePromise<{ + /** + * List of titles + */ + data: Array<Title>; + cursor: CursorObj; + }> { return __request(OpenAPI, { method: 'GET', url: '/titles', query: { + 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, 'word': word, 'status': status, 'rating': rating, @@ -111,9 +129,13 @@ export class DefaultService { * Get user titles * @param userId * @param cursor - * @param query + * @param word + * @param status + * @param watchStatus + * @param rating + * @param releaseYear + * @param releaseSeason * @param limit - * @param offset * @param fields * @returns UserTitle List of user titles * @throws ApiError @@ -121,22 +143,30 @@ export class DefaultService { public static getUsersTitles( userId: string, cursor?: string, - query?: string, + word?: string, + status?: TitleStatus, + watchStatus?: UserTitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, limit: number = 10, - offset?: number, fields: string = 'all', ): CancelablePromise<Array<UserTitle>> { return __request(OpenAPI, { method: 'GET', - url: '/users/{user_id}/titles', + url: '/users/{user_id}/titles/', path: { 'user_id': userId, }, query: { 'cursor': cursor, - 'query': query, + 'word': word, + 'status': status, + 'watch_status': watchStatus, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, 'limit': limit, - 'offset': offset, 'fields': fields, }, errors: { diff --git a/modules/frontend/src/api_/core/ApiError.ts b/modules/frontend/src/api_/core/ApiError.ts deleted file mode 100644 index ec7b16a..0000000 --- a/modules/frontend/src/api_/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: any; - public readonly request: ApiRequestOptions; - - constructor(request: ApiRequestOptions, response: ApiResult, message: string) { - super(message); - - this.name = 'ApiError'; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/modules/frontend/src/api_/core/ApiRequestOptions.ts b/modules/frontend/src/api_/core/ApiRequestOptions.ts deleted file mode 100644 index 93143c3..0000000 --- a/modules/frontend/src/api_/core/ApiRequestOptions.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiRequestOptions = { - readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record<string, any>; - readonly cookies?: Record<string, any>; - readonly headers?: Record<string, any>; - readonly query?: Record<string, any>; - readonly formData?: Record<string, any>; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record<number, string>; -}; diff --git a/modules/frontend/src/api_/core/ApiResult.ts b/modules/frontend/src/api_/core/ApiResult.ts deleted file mode 100644 index ee1126e..0000000 --- a/modules/frontend/src/api_/core/ApiResult.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiResult = { - readonly url: string; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly body: any; -}; diff --git a/modules/frontend/src/api_/core/CancelablePromise.ts b/modules/frontend/src/api_/core/CancelablePromise.ts deleted file mode 100644 index d70de92..0000000 --- a/modules/frontend/src/api_/core/CancelablePromise.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export class CancelError extends Error { - - constructor(message: string) { - super(message); - this.name = 'CancelError'; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise<T> implements Promise<T> { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise<T>; - #resolve?: (value: T | PromiseLike<T>) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike<T>) => void, - reject: (reason?: any) => void, - onCancel: OnCancel - ) => void - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise<T>((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike<T>): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isResolved = true; - if (this.#resolve) this.#resolve(value); - }; - - const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; - if (this.#reject) this.#reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved, - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected, - }); - - Object.defineProperty(onCancel, 'isCancelled', { - get: (): boolean => this.#isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise"; - } - - public then<TResult1 = T, TResult2 = never>( - onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null - ): Promise<TResult1 | TResult2> { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch<TResult = never>( - onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null - ): Promise<T | TResult> { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise<T> { - return this.#promise.finally(onFinally); - } - - public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isCancelled = true; - if (this.#cancelHandlers.length) { - try { - for (const cancelHandler of this.#cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn('Cancellation threw an error', error); - return; - } - } - this.#cancelHandlers.length = 0; - if (this.#reject) this.#reject(new CancelError('Request aborted')); - } - - public get isCancelled(): boolean { - return this.#isCancelled; - } -} diff --git a/modules/frontend/src/api_/core/OpenAPI.ts b/modules/frontend/src/api_/core/OpenAPI.ts deleted file mode 100644 index 185e5c3..0000000 --- a/modules/frontend/src/api_/core/OpenAPI.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; -type Headers = Record<string, string>; - -export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver<string> | undefined; - USERNAME?: string | Resolver<string> | undefined; - PASSWORD?: string | Resolver<string> | undefined; - HEADERS?: Headers | Resolver<Headers> | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', - VERSION: '1.0.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'include', - TOKEN: undefined, - USERNAME: undefined, - PASSWORD: undefined, - HEADERS: undefined, - ENCODE_PATH: undefined, -}; diff --git a/modules/frontend/src/api_/core/request.ts b/modules/frontend/src/api_/core/request.ts deleted file mode 100644 index 1dc6fef..0000000 --- a/modules/frontend/src/api_/core/request.ts +++ /dev/null @@ -1,323 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; -import FormData from 'form-data'; - -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { - return value !== undefined && value !== null; -}; - -export const isString = (value: any): value is string => { - return typeof value === 'string'; -}; - -export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; -}; - -export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); -}; - -export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; -}; - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } -}; - -export const getQueryString = (params: Record<string, any>): string => { - const qs: string[] = []; - - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; - - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); - - if (qs.length > 0) { - return `?${qs.join('&')}`; - } - - return ''; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; -}; - -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(v => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; - -export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { - if (typeof resolver === 'function') { - return (resolver as Resolver<T>)(options); - } - return resolver; -}; - -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) - .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), {} as Record<string, string>); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } - } - - return headers; -}; - -export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; -}; - -export const sendRequest = async <T>( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record<string, string>, - onCancel: OnCancel, - axiosClient: AxiosInstance -): Promise<AxiosResponse<T>> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel('The user aborted a request.')); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError<T>; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse<any>): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record<number, string> = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - } - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError(options, result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise<T> - * @throws ApiError - */ -export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - if (!onCancel.isCancelled) { - const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/modules/frontend/src/api_/index.ts b/modules/frontend/src/api_/index.ts deleted file mode 100644 index f0d09ee..0000000 --- a/modules/frontend/src/api_/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; -export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; - -export type { cursor } from './models/cursor'; -export type { Image } from './models/Image'; -export { ReleaseSeason } from './models/ReleaseSeason'; -export type { Review } from './models/Review'; -export type { Studio } from './models/Studio'; -export type { Tag } from './models/Tag'; -export type { Tags } from './models/Tags'; -export type { Title } from './models/Title'; -export { TitleStatus } from './models/TitleStatus'; -export type { User } from './models/User'; -export type { UserTitle } from './models/UserTitle'; -export { UserTitleStatus } from './models/UserTitleStatus'; - -export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api_/models/Image.ts b/modules/frontend/src/api_/models/Image.ts deleted file mode 100644 index 1317db7..0000000 --- a/modules/frontend/src/api_/models/Image.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Image = { - id?: number; - storage_type?: string; - image_path?: string; -}; - diff --git a/modules/frontend/src/api_/models/ReleaseSeason.ts b/modules/frontend/src/api_/models/ReleaseSeason.ts deleted file mode 100644 index 182b980..0000000 --- a/modules/frontend/src/api_/models/ReleaseSeason.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title release season - */ -export enum ReleaseSeason { - WINTER = 'winter', - SPRING = 'spring', - SUMMER = 'summer', - FALL = 'fall', -} diff --git a/modules/frontend/src/api_/models/Review.ts b/modules/frontend/src/api_/models/Review.ts deleted file mode 100644 index 9b453b7..0000000 --- a/modules/frontend/src/api_/models/Review.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Review = Record<string, any>; diff --git a/modules/frontend/src/api_/models/Studio.ts b/modules/frontend/src/api_/models/Studio.ts deleted file mode 100644 index 062695a..0000000 --- a/modules/frontend/src/api_/models/Studio.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -export type Studio = { - id: number; - name: string; - poster?: Image; - description?: string; -}; - diff --git a/modules/frontend/src/api_/models/Tag.ts b/modules/frontend/src/api_/models/Tag.ts deleted file mode 100644 index 665c724..0000000 --- a/modules/frontend/src/api_/models/Tag.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * A localized tag: keys are language codes (ISO 639-1), values are tag names - */ -export type Tag = Record<string, string>; diff --git a/modules/frontend/src/api_/models/TitleStatus.ts b/modules/frontend/src/api_/models/TitleStatus.ts deleted file mode 100644 index 811ece8..0000000 --- a/modules/frontend/src/api_/models/TitleStatus.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title status - */ -export enum TitleStatus { - FINISHED = 'finished', - ONGOING = 'ongoing', - PLANNED = 'planned', -} diff --git a/modules/frontend/src/api_/models/User.ts b/modules/frontend/src/api_/models/User.ts deleted file mode 100644 index 541028e..0000000 --- a/modules/frontend/src/api_/models/User.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type User = { - /** - * Unique user ID (primary key) - */ - id: number; - /** - * ID of the user avatar (references images table) - */ - avatar_id?: number | null; - /** - * User email - */ - mail?: string; - /** - * Username (alphanumeric + _ or -) - */ - nickname: string; - /** - * Display name - */ - disp_name?: string; - /** - * User description - */ - user_desc?: string; - /** - * Timestamp when the user was created - */ - creation_date?: string; -}; - diff --git a/modules/frontend/src/api_/models/UserTitle.ts b/modules/frontend/src/api_/models/UserTitle.ts deleted file mode 100644 index 26d5ddc..0000000 --- a/modules/frontend/src/api_/models/UserTitle.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type UserTitle = Record<string, any>; diff --git a/modules/frontend/src/api_/models/UserTitleStatus.ts b/modules/frontend/src/api_/models/UserTitleStatus.ts deleted file mode 100644 index 20651fe..0000000 --- a/modules/frontend/src/api_/models/UserTitleStatus.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * User's title status - */ -export enum UserTitleStatus { - FINISHED = 'finished', - PLANNED = 'planned', - DROPPED = 'dropped', - IN_PROGRESS = 'in-progress', -} diff --git a/modules/frontend/src/api_/services/DefaultService.ts b/modules/frontend/src/api_/services/DefaultService.ts deleted file mode 100644 index b0ae54d..0000000 --- a/modules/frontend/src/api_/services/DefaultService.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ReleaseSeason } from '../models/ReleaseSeason'; -import type { Title } from '../models/Title'; -import type { TitleStatus } from '../models/TitleStatus'; -import type { User } from '../models/User'; -import type { UserTitle } from '../models/UserTitle'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class DefaultService { - /** - * Get titles - * @param word - * @param status - * @param rating - * @param releaseYear - * @param releaseSeason - * @param limit - * @param offset - * @param fields - * @returns Title List of titles - * @throws ApiError - */ - public static getTitles( - word?: string, - status?: TitleStatus, - rating?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<Array<Title>> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles', - query: { - 'word': word, - 'status': status, - 'rating': rating, - 'release_year': releaseYear, - 'release_season': releaseSeason, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get title description - * @param titleId - * @param fields - * @returns Title Title description - * @throws ApiError - */ - public static getTitles1( - titleId: number, - fields: string = 'all', - ): CancelablePromise<Title> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles/{title_id}', - path: { - 'title_id': titleId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `Title not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user info - * @param userId - * @param fields - * @returns User User info - * @throws ApiError - */ - public static getUsers( - userId: string, - fields: string = 'all', - ): CancelablePromise<User> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `User not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user titles - * @param userId - * @param cursor - * @param query - * @param limit - * @param offset - * @param fields - * @returns UserTitle List of user titles - * @throws ApiError - */ - public static getUsersTitles( - userId: string, - cursor?: string, - query?: string, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<Array<UserTitle>> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - query: { - 'cursor': cursor, - 'query': query, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 500: `Unknown server error`, - }, - }); - } -} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index 77fea97..e0e8ab9 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,52 +1,103 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; +import type { CursorObj } from "../../api"; -interface ListViewProps<TItem> { - hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>; - renderHorizontal: (item: TItem) => React.ReactNode; - renderSquare: (item: TItem) => React.ReactNode; -} +export type ListViewProps<T> = { + fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; + pageSize?: number; + searchPlaceholder?: string; + setSearch: any; +}; -export function ListView<TItem>({ - hook, - renderHorizontal, - renderSquare -}: ListViewProps<TItem>) { - const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook; +export function ListView<T>({ + fetchItems, + renderItem, + pageSize = 20, + searchPlaceholder = "Search...", +}: ListViewProps<T>) { + const [items, setItems] = useState<T[]>([]); + const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); + const [error, setError] = useState<string | null>(null); + + const loadItems = async (reset: boolean = false) => { + try { + if (reset) { + setLoading(true); + setCursorObj(undefined); + } else { + setLoadingMore(true); + } + + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" + console.log("encoded cursor: " + cursorStr) + + const result = await fetchItems(cursorStr, pageSize); + + if (reset) setItems(result.items); + else setItems(prev => [...prev, ...result.items]); + + setCursorObj(result.cursor); + setError(null); + } catch (err: any) { + console.error(err); + setError("Failed to fetch items."); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + loadItems(true); + }, [search]); return ( - <div> - {/* Search + Layout Switcher */} - <div style={{ display: "flex", gap: 8, marginBottom: 16 }}> + <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> + <div className="w-full sm:w-4/5 flex gap-4 mb-8"> <input - placeholder="Search..." - value={search} + type="text" + placeholder={searchPlaceholder} + // value={search} onChange={e => setSearch(e.target.value)} + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" /> - - <button onClick={() => setViewMode("horizontal")}>Horizontal</button> - <button onClick={() => setViewMode("square")}>Square</button> - </div> - - {/* Items */} - <div - style={{ - display: "grid", - gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr", - gap: 12 - }} - > - {items.map(item => - viewMode === "horizontal" - ? renderHorizontal(item) - : renderSquare(item) - )} - </div> - - {hasMore && ( - <button onClick={loadMore} style={{ marginTop: 16 }}> - Load More + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} </button> + </div> + + {error && <div className="text-red-600 mb-6 font-medium">{error}</div>} + + <div + className={`w-full sm:w-4/5 grid gap-6 ${ + layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" + }`} + > + {items.map(item => renderItem(item, layout))} + </div> + + {cursorObj && ( + <div className="mt-8 flex justify-center w-full sm:w-4/5"> + <button + className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed" + onClick={() => loadItems(false)} + disabled={loadingMore} + > + {loadingMore ? "Loading..." : "Load More"} + </button> + </div> )} + + {loading && <div className="mt-20 font-medium">Loading...</div>} </div> ); -} \ No newline at end of file +} diff --git a/modules/frontend/src/components/ListView/useListView.tsx b/modules/frontend/src/components/ListView/useListView.tsx deleted file mode 100644 index 20c3597..0000000 --- a/modules/frontend/src/components/ListView/useListView.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState, useEffect } from "react"; -import type { FetchFunction } from "../../types/list"; - -export function useListView<TItem>(fetchFn: FetchFunction<TItem>) { - const [items, setItems] = useState<TItem[]>([]); - const [cursor, setCursor] = useState<string | undefined>(); - const [search, setSearch] = useState(""); - const [viewMode, setViewMode] = useState<"horizontal" | "square">("horizontal"); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - loadItems(true); - }, [search]); - - const loadItems = async (reset = false) => { - setIsLoading(true); - const result = await fetchFn({ - search, - cursor: reset ? undefined : cursor, - }); - - setItems(prev => reset ? result.items : [...prev, ...result.items]); - setCursor(result.nextCursor); - setIsLoading(false); - }; - - return { - items, - search, - setSearch, - viewMode, - setViewMode, - loadMore: () => loadItems(), - hasMore: Boolean(cursor), - isLoading, - }; -} \ No newline at end of file diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index c3a8159..cde6037 100644 --- a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -9,13 +9,13 @@ export function TitleCardHorizontal({ title }: { title: Title }) { border: "1px solid #ddd", borderRadius: 8 }}> - {title.posterUrl && ( - <img src={title.posterUrl} width={80} /> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={80} /> )} <div> - <h3>{title.name}</h3> - <p>{title.year} · {title.season} · Rating: {title.rating}</p> - <p>Status: {title.status}</p> + <h3>{title.title_names["en"]}</h3> + <p>{title.release_year} · {title.release_season} · Rating: {title.rating}</p> + <p>Status: {title.title_status}</p> </div> </div> ); diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx index 0fc0339..e21c258 100644 --- a/modules/frontend/src/components/cards/TitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -10,12 +10,12 @@ export function TitleCardSquare({ title }: { title: Title }) { borderRadius: 8, textAlign: "center" }}> - {title.posterUrl && ( - <img src={title.posterUrl} width={140} /> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={140} /> )} <div> - <h4>{title.name}</h4> - <small>{title.year} • {title.rating}</small> + <h4>{title.title_names["en"]}</h4> + <small>{title.release_year} • {title.rating}</small> </div> </div> ); diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index 08a3ac9..e20de02 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -1,68 +1,8 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@import "tailwindcss"; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { +html, body, #root { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} + padding: 0; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css index 9cc728b..f1d8c73 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css @@ -1,59 +1 @@ -.container { - padding: 24px; -} - -.header { - display: flex; - justify-content: space-between; - margin-bottom: 16px; -} - -.searchInput { - padding: 8px; - width: 240px; -} - -.list { - display: grid; - gap: 12px; -} - -.card { - display: flex; - padding: 10px; - border: 1px solid #ddd; - border-radius: 8px; - gap: 12px; -} - -.poster { - width: 80px; - height: 120px; - object-fit: cover; - border-radius: 4px; -} - -.posterPlaceholder { - width: 80px; - height: 120px; - background: #eee; - display: flex; - align-items: center; - justify-content: center; -} - -.cardInfo { - display: flex; - flex-direction: column; -} - -.loadMore { - margin-top: 16px; - padding: 8px 16px; -} - -.loader, -.error { - padding: 20px; - text-align: center; -} +@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 438d828..b59f737 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,114 +1,52 @@ -import React, { useEffect, useState } from "react"; +import { ListView } from "../../components/ListView/ListView"; import { DefaultService } from "../../api/services/DefaultService"; -import type { Title } from "../../api/models/Title"; -import styles from "./TitlesPage.module.css"; +import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; +import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import type { Title } from "../../api"; +import { useState, useEffect } from "react"; -const LIMIT = 20; - -const TitlesPage: React.FC = () => { - const [titles, setTitles] = useState<Title[]>([]); +const PAGE_SIZE = 20; +export default function TitlesPage() { const [search, setSearch] = useState(""); - const [offset, setOffset] = useState(0); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState<string | null>(null); + const loadTitles = async (cursor: string, limit: number) => { + const result = await DefaultService.getTitles( + cursor, + undefined, + true, + search, + undefined, + undefined, + undefined, + undefined, + limit, + undefined, + 'all' + ); - const fetchTitles = async (reset: boolean) => { - try { - if (reset) { - setLoading(true); - setOffset(0); - } else { - setLoadingMore(true); - } - - const result = await DefaultService.getTitles( - search || undefined, - undefined, // status - undefined, // rating - undefined, // release_year - undefined, // release_season - LIMIT, - reset ? 0 : offset, - "all" - ); - - if (reset) { - setTitles(result); - } else { - setTitles(prev => [...prev, ...result]); - } - - if (result.length > 0) { - setOffset(prev => prev + LIMIT); - } - - } catch (err) { - console.error(err); - setError("Failed to fetch titles."); - } finally { - setLoading(false); - setLoadingMore(false); - } + return { + items: result.data ?? [], + cursor: result.cursor ?? null, + }; }; - useEffect(() => { - fetchTitles(true); - }, [search]); - - if (loading) return <div className={styles.loader}>Loading...</div>; - if (error) return <div className={styles.error}>{error}</div>; - return ( - <div className={styles.container}> - <div className={styles.header}> - <h1>Titles</h1> + <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center">Titles</h1> - <input - className={styles.searchInput} - placeholder="Search titles..." - value={search} - onChange={e => setSearch(e.target.value)} - /> - </div> - - <div className={styles.list}> - {titles.map((t) => ( - <div key={t.id} className={styles.card}> - {t.poster_id ? ( - <img - src={`/images/${t.poster_id}.png`} - alt="Poster" - className={styles.poster} - /> - ) : ( - <div className={styles.posterPlaceholder}>No Image</div> - )} - - <div className={styles.cardInfo}> - <h3 className={styles.titleName}>{t.name}</h3> - <p className={styles.meta}> - {t.release_year} • {t.release_season} - </p> - <p className={styles.rating}>Rating: {t.rating}</p> - <p className={styles.status}>{t.status}</p> - </div> - </div> - ))} - </div> - - {titles.length > 0 && ( - <button - className={styles.loadMore} - onClick={() => fetchTitles(false)} - disabled={loadingMore} - > - {loadingMore ? "Loading..." : "Load More"} - </button> - )} + <ListView<Title> + pageSize={PAGE_SIZE} + fetchItems={loadTitles} + searchPlaceholder="Search titles..." + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + setSearch={setSearch} + /> </div> ); -}; +} -export default TitlesPage; diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 4cfbdd0..6c261e6 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + tailwindcss() + ], server: { host: '127.0.0.1', port: 8083, From 31a95fabeaa860b02d3be9b565238232235156c1 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Wed, 19 Nov 2025 11:06:09 +0300 Subject: [PATCH 48/97] fix: useEffect --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index b59f737..4aeb5ec 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -3,7 +3,7 @@ import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import type { Title } from "../../api"; -import { useState, useEffect } from "react"; +import { useState } from "react"; const PAGE_SIZE = 20; export default function TitlesPage() { From af0492cdf1101f44e1e916f9bd9faa42787040c4 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 00:01:48 +0300 Subject: [PATCH 49/97] feat: cursor implemented --- api/openapi.yaml | 1 + api/schemas/_index.yaml | 2 +- modules/backend/handlers/common.go | 65 ++++++++++++ modules/backend/handlers/cursor.go | 156 +++++++++++++++++++++++++++++ modules/backend/handlers/titles.go | 38 +++++-- modules/backend/queries.sql | 124 +++++++++++++++-------- sql/queries.sql.go | 154 ++++++++++++++++++---------- sql/sqlc.yaml | 1 + 8 files changed, 435 insertions(+), 106 deletions(-) create mode 100644 modules/backend/handlers/cursor.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 281fe82..c8bdbc4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -20,3 +20,4 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" + \ No newline at end of file diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml index ac49f37..d893ced 100644 --- a/api/schemas/_index.yaml +++ b/api/schemas/_index.yaml @@ -1,5 +1,5 @@ CursorObj: - $ref: ./CursorObj.yaml + $ref: "./CursorObj.yaml" TitleSort: $ref: "./TitleSort.yaml" Image: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index b85d022..3d61b91 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -13,6 +13,71 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } +// type Cursor interface { +// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) + +// Values() map[string]interface{} +// // for logs only +// Type() string +// } + +// type CursorByID struct { +// ID int64 +// } + +// func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) { +// var cur CursorByID +// if err := json.Unmarshal(data, &cur); err != nil { +// return nil, fmt.Errorf("invalid cursor (id): %w", err) +// } +// if cur.ID == 0 { +// return nil, errors.New("cursor id must be non-zero") +// } +// return cur, nil +// } + +// func (c CursorByID) Values() map[string]interface{} { +// return map[string]interface{}{ +// "cursor_id": c.ID, +// "cursor_year": nil, +// "cursor_rating": nil, +// } +// } + +// func (c CursorByID) Type() string { return "id" } + +// func NewCursor(sortBy string) (Cursor, error) { +// switch Type(sortBy) { +// case TypeID: +// return CursorByID{}, nil +// case TypeYear: +// return CursorByYear{}, nil +// case TypeRating: +// return CursorByRating{}, nil +// default: +// return nil, fmt.Errorf("unsupported sort_by: %q", sortBy) +// } +// } + +// decodes a base64-encoded JSON string into a CursorObj +// Returns the parsed CursorObj and an error +// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) { + +// // Decode base64 +// decoded, err := base64.StdEncoding.DecodeString(encoded) +// if err != nil { +// return nil, fmt.Errorf("parseCursor: %v", err) +// } + +// // Parse JSON +// var cursor oapi.CursorObj +// if err := json.Unmarshal(decoded, &cursor); err != nil { +// return nil, fmt.Errorf("parseCursor: %v", err) +// } + +// return &cursor, nil +// } + func parseInt64(s string) (int32, error) { i, err := strconv.ParseInt(s, 10, 64) return int32(i), err diff --git a/modules/backend/handlers/cursor.go b/modules/backend/handlers/cursor.go new file mode 100644 index 0000000..fd2821e --- /dev/null +++ b/modules/backend/handlers/cursor.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "strconv" +) + +// ParseCursorInto parses an opaque base64 cursor and injects values into target struct. +// +// Supported sort types: +// - "id" → sets CursorID (must be *int64) +// - "year" → sets CursorID (*int64) + CursorYear (*int32) +// - "rating" → sets CursorID (*int64) + CursorRating (*float64) +// +// Target struct may have any subset of these fields (e.g. only CursorID). +// Unknown fields are ignored. Missing fields → values are dropped (safe). +// +// Returns error if cursor is invalid or inconsistent with sort_by. +func ParseCursorInto(sortBy, cursorStr string, target any) error { + if cursorStr == "" { + return nil // no cursor → nothing to do + } + + // 1. Decode cursor + payload, err := decodeCursor(cursorStr) + if err != nil { + return err + } + + // 2. Extract ID (required for all types) + id, err := extractInt64(payload, "id") + if err != nil { + return fmt.Errorf("cursor: %v", err) + } + + // 3. Get reflect value of target (must be ptr to struct) + v := reflect.ValueOf(target) + if v.Kind() != reflect.Ptr || v.IsNil() { + return fmt.Errorf("target must be non-nil pointer to struct") + } + v = v.Elem() + if v.Kind() != reflect.Struct { + return fmt.Errorf("target must be pointer to struct") + } + + // 4. Helper: set field if exists and compatible + setField := func(fieldName string, value any) { + f := v.FieldByName(fieldName) + if !f.IsValid() || !f.CanSet() { + return // field not found or unexported + } + ft := f.Type() + vv := reflect.ValueOf(value) + + // Case: field is *T, value is T → wrap in pointer + if ft.Kind() == reflect.Ptr { + elemType := ft.Elem() + if vv.Type().AssignableTo(elemType) { + ptr := reflect.New(elemType) + ptr.Elem().Set(vv) + f.Set(ptr) + } + // nil → leave as zero (nil pointer) + } else if vv.Type().AssignableTo(ft) { + f.Set(vv) + } + // else: type mismatch → silently skip (safe) + } + + // 5. Dispatch by sort type + switch sortBy { + case "id": + setField("CursorID", id) + + case "year": + setField("CursorID", id) + param, err := extractString(payload, "param") + if err != nil { + return fmt.Errorf("cursor year: %w", err) + } + year, err := strconv.Atoi(param) + if err != nil { + return fmt.Errorf("cursor year: param must be integer, got %q", param) + } + setField("CursorYear", int32(year)) // or int, depending on your schema + + case "rating": + setField("CursorID", id) + param, err := extractString(payload, "param") + if err != nil { + return fmt.Errorf("cursor rating: %w", err) + } + rating, err := strconv.ParseFloat(param, 64) + if err != nil { + return fmt.Errorf("cursor rating: param must be float, got %q", param) + } + setField("CursorRating", rating) + + default: + return fmt.Errorf("unsupported sort_by: %q", sortBy) + } + + return nil +} + +// --- helpers --- +func decodeCursor(cursorStr string) (map[string]any, error) { + data, err := base64.RawURLEncoding.DecodeString(cursorStr) + if err != nil { + data, err = base64.StdEncoding.DecodeString(cursorStr) + if err != nil { + return nil, fmt.Errorf("invalid base64 cursor") + } + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("invalid cursor JSON: %w", err) + } + return m, nil +} + +func extractInt64(m map[string]any, key string) (int64, error) { + v, ok := m[key] + if !ok { + return 0, fmt.Errorf("missing %q", key) + } + switch x := v.(type) { + case float64: + if x == float64(int64(x)) { + return int64(x), nil + } + case string: + i, err := strconv.ParseInt(x, 10, 64) + if err == nil { + return i, nil + } + case int64, int, int32: + return reflect.ValueOf(x).Int(), nil + } + return 0, fmt.Errorf("%q must be integer", key) +} + +func extractString(m map[string]interface{}, key string) (string, error) { + v, ok := m[key] + if !ok { + return "", fmt.Errorf("missing %q", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%q must be string", key) + } + return s, nil +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index f187cc4..fc3d621 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -218,9 +218,17 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) - cursor := oapi.CursorObj{ - Id: 1, - } + + // old_cursor := oapi.CursorObj{ + // Id: 1, + // } + + // if request.Params.Cursor != nil { + // if old_cursor, err := parseCursor(*request.Params.Cursor); err != nil { + // log.Errorf("%v", err) + // return oapi.GetTitles400Response{}, err + // } + // } word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -233,17 +241,29 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } - // param = nil means it will not be used - titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ + + params := sqlc.SearchTitlesParams{ Word: word, Status: status, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, Forward: true, - SortBy: "id", - Limit: request.Params.Limit, - }) + // SortBy: "id", + Limit: request.Params.Limit, + } + + if request.Params.Sort != nil { + if request.Params.Cursor != nil { + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, nil + } + } + } + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, params) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil @@ -262,5 +282,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles = append(opai_titles, t) } - return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil + return oapi.GetTitles200JSONResponse{Cursor: oapi.CursorObj{}, Data: opai_titles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index c05edff..7118781 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -81,56 +81,96 @@ WHERE id = sqlc.arg('title_id')::bigint; SELECT * FROM titles -WHERE - CASE - WHEN sqlc.narg('word')::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +WHERE + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (release_year > sqlc.narg('cursor_year')::int) OR + (release_year = sqlc.narg('cursor_year')::int AND id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (rating > sqlc.narg('cursor_rating')::float) OR + (rating = sqlc.narg('cursor_rating')::float AND id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (release_year < sqlc.narg('cursor_year')::int) OR + (release_year = sqlc.narg('cursor_year')::int AND id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (rating < sqlc.narg('cursor_rating')::float) OR + (rating = sqlc.narg('cursor_rating')::float AND id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END END + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) -ORDER BY - -- Основной ключ: выбранное поле - CASE - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END ASC, - CASE - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END DESC, - -- Вторичный ключ: id — только если НЕ сортируем по id - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id - END ASC, - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id - END DESC +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN id + WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + END + END ASC, + CASE WHEN NOT sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN id + WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + END + END DESC, + + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN id END ASC + LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit --- OFFSET sqlc.narg('offset')::int; -- name: SearchUserTitles :many SELECT diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 5a1d13c..7d970cb 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -179,7 +179,7 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, er return nil, err } defer rows.Close() - var items [][]byte + items := [][]byte{} for rows.Next() { var tag_names []byte if err := rows.Scan(&tag_names); err != nil { @@ -289,84 +289,131 @@ const searchTitles = `-- name: SearchTitles :many SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len FROM titles -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +WHERE + CASE + WHEN $1::boolean THEN + -- forward: greater than cursor (next page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (release_year > $3::int) OR + (release_year = $3::int AND id > $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (rating > $5::float) OR + (rating = $5::float AND id > $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (id > $4::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (release_year < $3::int) OR + (release_year = $3::int AND id < $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (rating < $5::float) OR + (rating = $5::float AND id < $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (id < $4::bigint) + + ELSE true + END END - AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) - AND ($3::float IS NULL OR rating >= $3::float) - AND ($4::int IS NULL OR release_year = $4::int) - AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) -ORDER BY - -- Основной ключ: выбранное поле - CASE - WHEN $6::boolean AND $7::text = 'id' THEN id - WHEN $6::boolean AND $7::text = 'year' THEN release_year - WHEN $6::boolean AND $7::text = 'rating' THEN rating - -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END ASC, - CASE - WHEN NOT $6::boolean AND $7::text = 'id' THEN id - WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year - WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating - -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END DESC, + AND ( + CASE + WHEN $6::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($6::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) - -- Вторичный ключ: id — только если НЕ сортируем по id - CASE - WHEN $7::text != 'id' AND $6::boolean THEN id - END ASC, - CASE - WHEN $7::text != 'id' AND NOT $6::boolean THEN id - END DESC -LIMIT COALESCE($8::int, 100) + AND ($7::title_status_t IS NULL OR title_status = $7::title_status_t) + AND ($8::float IS NULL OR rating >= $8::float) + AND ($9::int IS NULL OR release_year = $9::int) + AND ($10::release_season_t IS NULL OR release_season = $10::release_season_t) + +ORDER BY + CASE WHEN $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN id + WHEN $2::text = 'year' THEN release_year + WHEN $2::text = 'rating' THEN rating + END + END ASC, + CASE WHEN NOT $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN id + WHEN $2::text = 'year' THEN release_year + WHEN $2::text = 'rating' THEN rating + END + END DESC, + + CASE WHEN $2::text <> 'id' THEN id END ASC + +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` Status *TitleStatusT `json:"status"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` Limit *int32 `json:"limit"` } func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { rows, err := q.db.Query(ctx, searchTitles, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, arg.Word, arg.Status, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.Forward, - arg.SortBy, arg.Limit, ) if err != nil { return nil, err } defer rows.Close() - var items []Title + items := []Title{} for rows.Next() { var i Title if err := rows.Scan( @@ -464,7 +511,6 @@ type SearchUserTitlesRow struct { } // 100 is default limit -// OFFSET sqlc.narg('offset')::int; func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, arg.Word, @@ -479,7 +525,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara return nil, err } defer rows.Close() - var items []SearchUserTitlesRow + items := []SearchUserTitlesRow{} for rows.Next() { var i SearchUserTitlesRow if err := rows.Scan( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f74d2ad..94b9fb4 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -12,6 +12,7 @@ sql: sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: true emit_pointers_for_null_types: true + emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: - db_type: "uuid" nullable: false From e16adcf6cd5f36bad9ec61adc81af3443e04a277 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 02:04:40 +0300 Subject: [PATCH 50/97] feat: now back sendind real new cursor --- modules/backend/handlers/cursor.go | 6 +++--- modules/backend/handlers/titles.go | 22 +++++++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/backend/handlers/cursor.go b/modules/backend/handlers/cursor.go index fd2821e..e3b48b0 100644 --- a/modules/backend/handlers/cursor.go +++ b/modules/backend/handlers/cursor.go @@ -38,7 +38,7 @@ func ParseCursorInto(sortBy, cursorStr string, target any) error { // 3. Get reflect value of target (must be ptr to struct) v := reflect.ValueOf(target) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return fmt.Errorf("target must be non-nil pointer to struct") } v = v.Elem() @@ -56,7 +56,7 @@ func ParseCursorInto(sortBy, cursorStr string, target any) error { vv := reflect.ValueOf(value) // Case: field is *T, value is T → wrap in pointer - if ft.Kind() == reflect.Ptr { + if ft.Kind() == reflect.Pointer { elemType := ft.Elem() if vv.Type().AssignableTo(elemType) { ptr := reflect.New(elemType) @@ -143,7 +143,7 @@ func extractInt64(m map[string]any, key string) (int64, error) { return 0, fmt.Errorf("%q must be integer", key) } -func extractString(m map[string]interface{}, key string) (string, error) { +func extractString(m map[string]any, key string) (string, error) { v, ok := m[key] if !ok { return "", fmt.Errorf("missing %q", key) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index fc3d621..aea6037 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -6,6 +6,7 @@ import ( "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "strconv" "github.com/jackc/pgx/v5" log "github.com/sirupsen/logrus" @@ -249,11 +250,12 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, Forward: true, - // SortBy: "id", - Limit: request.Params.Limit, + SortBy: "id", + Limit: request.Params.Limit, } if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { @@ -272,6 +274,8 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles204Response{}, nil } + var new_cursor oapi.CursorObj + for _, title := range titles { t, err := s.mapTitle(ctx, title) @@ -280,7 +284,19 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles500Response{}, nil } opai_titles = append(opai_titles, t) + + new_cursor.Id = t.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := string(*t.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) + new_cursor.Param = &tmp + } + } } - return oapi.GetTitles200JSONResponse{Cursor: oapi.CursorObj{}, Data: opai_titles}, nil + return oapi.GetTitles200JSONResponse{Cursor: new_cursor, Data: opai_titles}, nil } From 73547187beac3e431e1613e13176cc82b88393aa Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 02:58:51 +0300 Subject: [PATCH 51/97] fix: search titles rewritten --- sql/queries.sql.go | 101 +++++++++++++++++++++++++++++++++------------ 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 7d970cb..c043f8a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -287,8 +287,23 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams const searchTitles = `-- name: SearchTitles :many SELECT - id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM titles + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + g.tag_names as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + WHERE CASE WHEN $1::boolean THEN @@ -296,17 +311,17 @@ WHERE CASE $2::text WHEN 'year' THEN ($3::int IS NULL) OR - (release_year > $3::int) OR - (release_year = $3::int AND id > $4::bigint) + (t.release_year > $3::int) OR + (t.release_year = $3::int AND t.id > $4::bigint) WHEN 'rating' THEN ($5::float IS NULL) OR - (rating > $5::float) OR - (rating = $5::float AND id > $4::bigint) + (t.rating > $5::float) OR + (t.rating = $5::float AND t.id > $4::bigint) WHEN 'id' THEN ($4::bigint IS NULL) OR - (id > $4::bigint) + (t.id > $4::bigint) ELSE true -- fallback END @@ -316,17 +331,17 @@ WHERE CASE $2::text WHEN 'year' THEN ($3::int IS NULL) OR - (release_year < $3::int) OR - (release_year = $3::int AND id < $4::bigint) + (t.release_year < $3::int) OR + (t.release_year = $3::int AND t.id < $4::bigint) WHEN 'rating' THEN ($5::float IS NULL) OR - (rating < $5::float) OR - (rating = $5::float AND id < $4::bigint) + (t.rating < $5::float) OR + (t.rating = $5::float AND t.id < $4::bigint) WHEN 'id' THEN ($4::bigint IS NULL) OR - (id < $4::bigint) + (t.id < $4::bigint) ELSE true END @@ -339,7 +354,7 @@ WHERE SELECT bool_and( EXISTS ( SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) + FROM jsonb_each_text(t.title_names) AS t(key, val) WHERE val ILIKE pattern ) ) @@ -355,28 +370,28 @@ WHERE END ) - AND ($7::title_status_t IS NULL OR title_status = $7::title_status_t) - AND ($8::float IS NULL OR rating >= $8::float) - AND ($9::int IS NULL OR release_year = $9::int) - AND ($10::release_season_t IS NULL OR release_season = $10::release_season_t) + AND ($7::title_status_t IS NULL OR t.title_status = $7::title_status_t) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) ORDER BY CASE WHEN $1::boolean THEN CASE - WHEN $2::text = 'id' THEN id - WHEN $2::text = 'year' THEN release_year - WHEN $2::text = 'rating' THEN rating + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating END END ASC, CASE WHEN NOT $1::boolean THEN CASE - WHEN $2::text = 'id' THEN id - WHEN $2::text = 'year' THEN release_year - WHEN $2::text = 'rating' THEN rating + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating END END DESC, - CASE WHEN $2::text <> 'id' THEN id END ASC + CASE WHEN $2::text <> 'id' THEN t.id END ASC LIMIT COALESCE($11::int, 100) ` @@ -395,7 +410,31 @@ type SearchTitlesParams struct { Limit *int32 `json:"limit"` } -func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { +type SearchTitlesRow struct { + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType NullStorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType NullStorageTypeT `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + +func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { rows, err := q.db.Query(ctx, searchTitles, arg.Forward, arg.SortBy, @@ -413,9 +452,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T return nil, err } defer rows.Close() - items := []Title{} + items := []SearchTitlesRow{} for rows.Next() { - var i Title + var i SearchTitlesRow if err := rows.Scan( &i.ID, &i.TitleNames, @@ -430,6 +469,14 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T &i.EpisodesAired, &i.EpisodesAll, &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, ); err != nil { return nil, err } From 870bbe2395ae08481f391b4097d8dd0c1b08793b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 05:14:42 +0300 Subject: [PATCH 52/97] feat: now status can be array --- api/paths/titles.yaml | 6 ++++++ api/schemas/Image.yaml | 3 ++- api/schemas/Studio.yaml | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index e868ed6..3c5cf76 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -15,7 +15,13 @@ get: - in: query name: status schema: + type: array + items: $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: true + - in: query name: rating schema: diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml index 7226b29..4ae3cb7 100644 --- a/api/schemas/Image.yaml +++ b/api/schemas/Image.yaml @@ -1,6 +1,7 @@ type: object properties: - id: +# id выпиливаем + id: type: integer format: int64 storage_type: diff --git a/api/schemas/Studio.yaml b/api/schemas/Studio.yaml index 35b40a8..26a2adf 100644 --- a/api/schemas/Studio.yaml +++ b/api/schemas/Studio.yaml @@ -3,6 +3,7 @@ required: - id - name properties: +# id не нужен id: type: integer format: int64 From f1f7feffaa6ab39a3c6579501cc4950645c6201f Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 22 Nov 2025 05:45:54 +0300 Subject: [PATCH 53/97] feat: /titles page with search and sort functionality. Website header added --- modules/frontend/package-lock.json | 240 ++++++++++++++++++ modules/frontend/package.json | 1 + modules/frontend/src/App.css | 42 --- modules/frontend/src/App.tsx | 3 + .../frontend/src/components/Header/Header.tsx | 90 +++++++ .../components/LayoutSwitch/LayoutSwitch.tsx | 28 ++ .../src/components/ListView/ListView.tsx | 96 ++----- .../src/components/SearchBar/SearchBar.tsx | 34 +++ .../TitlesSortBox/TitlesSortBox.tsx | 67 +++++ modules/frontend/src/index.css | 1 + .../src/pages/TitlesPage/TitlesPage.tsx | 176 ++++++++++--- modules/frontend/vite.config.ts | 2 +- 12 files changed, 625 insertions(+), 155 deletions(-) create mode 100644 modules/frontend/src/components/Header/Header.tsx create mode 100644 modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx create mode 100644 modules/frontend/src/components/SearchBar/SearchBar.tsx create mode 100644 modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index f5dde46..40bb520 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", @@ -30,6 +31,9 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -906,6 +910,79 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -1057,6 +1134,103 @@ "node": ">= 8" } }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -1350,6 +1524,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -1607,6 +1790,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2221,6 +2431,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4086,6 +4305,12 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -4177,6 +4402,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4301,6 +4532,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index cc468cf..e0b65ba 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", diff --git a/modules/frontend/src/App.css b/modules/frontend/src/App.css index b9d355d..e69de29 100644 --- a/modules/frontend/src/App.css +++ b/modules/frontend/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index f67c37e..909ad6c 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,10 +2,13 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { Header } from "./components/Header/Header"; const App: React.FC = () => { + const username = "nihonium"; return ( <Router> + <Header username={username} /> <Routes> <Route path="/users/:id" element={<UserPage />} /> <Route path="/titles" element={<TitlesPage />} /> diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx new file mode 100644 index 0000000..98b1295 --- /dev/null +++ b/modules/frontend/src/components/Header/Header.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; + +type HeaderProps = { + username?: string; +}; + +export const Header: React.FC<HeaderProps> = ({ username }) => { + const [menuOpen, setMenuOpen] = useState(false); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + return ( + <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex justify-between h-16 items-center"> + + {/* Левый блок — логотип / название */} + <div className="flex-shrink-0"> + <Link to="/" className="text-xl font-bold text-gray-800 hover:text-blue-600"> + NyanimeDB + </Link> + </div> + + {/* Центр — ссылки на разделы (desktop) */} + <nav className="hidden md:flex space-x-4"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600"> + Titles + </Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600"> + Users + </Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600"> + About + </Link> + </nav> + + {/* Правый блок — профиль */} + <div className="hidden md:flex items-center space-x-4"> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium"> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium"> + Login + </Link> + )} + </div> + + {/* Бургер для мобильного */} + <div className="md:hidden flex items-center"> + <button + onClick={toggleMenu} + className="p-2 rounded-md hover:bg-gray-200 transition" + > + {menuOpen ? ( + <XMarkIcon className="w-6 h-6 text-gray-800" /> + ) : ( + <Bars3Icon className="w-6 h-6 text-gray-800" /> + )} + </button> + </div> + + </div> + </div> + + {/* Мобильное меню */} + {menuOpen && ( + <div className="md:hidden bg-white border-t border-gray-200 shadow-md"> + <nav className="flex flex-col p-4 space-y-2"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Titles</Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Users</Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>About</Link> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + Login + </Link> + )} + </nav> + </div> + )} + </header> + ); +}; diff --git a/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx new file mode 100644 index 0000000..679feea --- /dev/null +++ b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; + +export type LayoutSwitchProps = { + layout: "square" | "horizontal" + setLayout: (value: React.SetStateAction<"square" | "horizontal">) => void +}; + +export function LayoutSwitch({ + layout, + setLayout +}: LayoutSwitchProps) { + + return ( + <div className="flex justify-end"> + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" + ? <Squares2X2Icon className="w-6 h-6" /> + : <Bars3Icon className="w-6 h-6" /> + } + </button> + </div> + ); +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index e0e8ab9..67488c0 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,103 +1,49 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; -import type { CursorObj } from "../../api"; export type ListViewProps<T> = { - fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + items: T[]; + layout: "square" | "horizontal"; renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; - pageSize?: number; - searchPlaceholder?: string; - setSearch: any; + onLoadMore: () => void; + hasMore: boolean; + loadingMore: boolean; }; export function ListView<T>({ - fetchItems, + items, + layout, renderItem, - pageSize = 20, - searchPlaceholder = "Search...", + onLoadMore, + hasMore, + loadingMore }: ListViewProps<T>) { - const [items, setItems] = useState<T[]>([]); - const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); - const [error, setError] = useState<string | null>(null); - - const loadItems = async (reset: boolean = false) => { - try { - if (reset) { - setLoading(true); - setCursorObj(undefined); - } else { - setLoadingMore(true); - } - - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" - console.log("encoded cursor: " + cursorStr) - - const result = await fetchItems(cursorStr, pageSize); - - if (reset) setItems(result.items); - else setItems(prev => [...prev, ...result.items]); - - setCursorObj(result.cursor); - setError(null); - } catch (err: any) { - console.error(err); - setError("Failed to fetch items."); - } finally { - setLoading(false); - setLoadingMore(false); - } - }; - - useEffect(() => { - loadItems(true); - }, [search]); return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - <div className="w-full sm:w-4/5 flex gap-4 mb-8"> - <input - type="text" - placeholder={searchPlaceholder} - // value={search} - onChange={e => setSearch(e.target.value)} - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" - /> - <button - className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" - onClick={() => - setLayout(prev => (prev === "square" ? "horizontal" : "square")) - }> - {layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} - </button> - </div> - - {error && <div className="text-red-600 mb-6 font-medium">{error}</div>} - + <div className="w-full flex flex-col items-center"> + {/* Items */} <div className={`w-full sm:w-4/5 grid gap-6 ${ - layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" + layout === "square" + ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + : "grid-cols-1" }`} > {items.map(item => renderItem(item, layout))} </div> - {cursorObj && ( - <div className="mt-8 flex justify-center w-full sm:w-4/5"> + {/* Load More */} + {hasMore && ( + <div className="mt-8"> <button - className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed" - onClick={() => loadItems(false)} + className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50" disabled={loadingMore} + onClick={onLoadMore} > {loadingMore ? "Loading..." : "Load More"} </button> </div> )} - - {loading && <div className="mt-20 font-medium">Loading...</div>} </div> ); } diff --git a/modules/frontend/src/components/SearchBar/SearchBar.tsx b/modules/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..87aee66 --- /dev/null +++ b/modules/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,34 @@ +type SearchBarProps = { + placeholder?: string; + search: string; + setSearch: (value: string) => void; +}; + +export function SearchBar({ + placeholder = "Search...", + search, + setSearch, +}: SearchBarProps) { + return ( + <div className="w-full"> + <input + type="text" + value={search} + placeholder={placeholder} + onChange={(e) => setSearch(e.target.value)} + className=" + w-full + px-4 + py-2 + border + border-gray-300 + rounded-lg + focus:outline-none + focus:ring-2 + focus:ring-blue-500 + text-black + " + /> + </div> + ); +} diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx new file mode 100644 index 0000000..f1f8195 --- /dev/null +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import type { TitleSort } from "../../api"; +import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; + +type TitlesSortBoxProps = { + sort: TitleSort; + setSort: (value: TitleSort) => void; + sortForward: boolean; + setSortForward: (value: boolean) => void; +}; + +const SORT_OPTIONS: TitleSort[] = ["id", "rating", "year", "views"]; + +export function TitlesSortBox({ + sort, + setSort, + sortForward, + setSortForward, +}: TitlesSortBoxProps) { + const [open, setOpen] = useState(false); + + const toggleSortDirection = () => setSortForward(!sortForward); + const handleSortSelect = (newSort: TitleSort) => { + setSort(newSort); + setOpen(false); + }; + + return ( + <div className="inline-flex relative z-50"> + {/* Левая часть — смена направления */} + <button + onClick={toggleSortDirection} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-l-lg transition" + > + {sortForward ? <ArrowUpIcon className="w-4 h-4 mr-1" /> : <ArrowDownIcon className="w-4 h-4 mr-1" />} + <span className="text-sm font-medium">Order</span> + </button> + + {/* Правая часть — выбор параметра */} + <button + onClick={() => setOpen(!open)} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 border-l-0 rounded-r-lg transition" + > + <span className="text-sm font-medium">{sort}</span> + <ChevronDownIcon className="w-4 h-4 ml-1" /> + </button> + + {/* Dropdown */} + {open && ( + <ul className="absolute top-full left-0 mt-1 w-40 bg-white border border-gray-300 rounded-md shadow-lg z-[1000]"> + {SORT_OPTIONS.map(option => ( + <li key={option}> + <button + className={`w-full text-left px-4 py-2 hover:bg-gray-100 transition ${ + option === sort ? "font-bold bg-gray-100" : "" + }`} + onClick={() => handleSortSelect(option)} + > + {option} + </button> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index e20de02..7b32a9b 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -5,4 +5,5 @@ html, body, #root { padding: 0; width: 100%; height: 100%; + @apply text-black bg-white; } \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 4aeb5ec..0fec3c8 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,52 +1,154 @@ +import { useEffect, useState } from "react"; import { ListView } from "../../components/ListView/ListView"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { Title } from "../../api"; -import { useState } from "react"; +import type { CursorObj, Title, TitleSort } from "../../api"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; + +const PAGE_SIZE = 10; -const PAGE_SIZE = 20; export default function TitlesPage() { + const [titles, setTitles] = useState<Title[]>([]); + const [nextPage, setNextPage] = useState<Title[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); - const loadTitles = async (cursor: string, limit: number) => { - const result = await DefaultService.getTitles( - cursor, - undefined, - true, - search, - undefined, - undefined, - undefined, - undefined, - limit, - undefined, - 'all' - ); + const fetchPage = async (cursorObj: CursorObj | null) => { + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; - return { - items: result.data ?? [], - cursor: result.cursor ?? null, - }; + try { + const result = await DefaultService.getTitles( + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, + undefined, + undefined, + undefined, + PAGE_SIZE, + undefined, + "all" + ); + + if ((result === undefined) || !result.data?.length) { + return { items: [], nextCursor: null }; + } + return { + items: result.data ?? [], + nextCursor: result.cursor ?? null + }; + } catch (err: any) { + if (err.status === 204) { + return { items: [], nextCursor: null }; + } + throw err; + } }; - return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - - <h1 className="text-4xl font-bold mb-6 text-center">Titles</h1> + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoading(true); + setTitles([]); + setNextPage([]); + setCursor(null); - <ListView<Title> - pageSize={PAGE_SIZE} - fetchItems={loadTitles} - searchPlaceholder="Search titles..." - renderItem={(title, layout) => - layout === "square" - ? <TitleCardSquare title={title} /> - : <TitleCardHorizontal title={title} /> - } - setSearch={setSearch} - /> + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoading(false); + }; + + initLoad(); + }, [search, sort, sortForward]); + + +const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + // Подгружаем следующую страницу с сервера + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) { + setNextPage(next.items); + } + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + // Любой сценарий – выключаем loadingMore + setLoadingMore(false); +}; + + + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center text-black">Titles</h1> + + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox + sort={sort} + setSort={setSort} + sortForward={sortForward} + setSortForward={setSortForward} + /> + </div> + + {loading && <div className="mt-20 font-medium text-black">Loading...</div>} + + {!loading && titles.length === 0 && ( + <div className="mt-20 font-medium text-black">No titles found.</div> + )} + + {titles.length > 0 && ( + <> + <ListView<Title> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length == 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} </div> ); } - diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 6c261e6..554d630 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ tailwindcss() ], server: { - host: '127.0.0.1', + host: '0.0.0.0', port: 8083, }, }) From 86e3df2205e13f03491be59acc80ee7c388e9250 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 22 Nov 2025 05:45:54 +0300 Subject: [PATCH 54/97] feat: /titles page with search and sort functionality. Website header added --- modules/frontend/package-lock.json | 240 ++++++++++++++++++ modules/frontend/package.json | 1 + modules/frontend/src/App.css | 42 --- modules/frontend/src/App.tsx | 3 + .../frontend/src/components/Header/Header.tsx | 90 +++++++ .../components/LayoutSwitch/LayoutSwitch.tsx | 28 ++ .../src/components/ListView/ListView.tsx | 96 ++----- .../src/components/SearchBar/SearchBar.tsx | 34 +++ .../TitlesSortBox/TitlesSortBox.tsx | 67 +++++ modules/frontend/src/index.css | 1 + .../src/pages/TitlesPage/TitlesPage.tsx | 176 ++++++++++--- modules/frontend/vite.config.ts | 2 +- 12 files changed, 625 insertions(+), 155 deletions(-) create mode 100644 modules/frontend/src/components/Header/Header.tsx create mode 100644 modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx create mode 100644 modules/frontend/src/components/SearchBar/SearchBar.tsx create mode 100644 modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index f5dde46..40bb520 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", @@ -30,6 +31,9 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -906,6 +910,79 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -1057,6 +1134,103 @@ "node": ">= 8" } }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -1350,6 +1524,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -1607,6 +1790,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2221,6 +2431,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4086,6 +4305,12 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -4177,6 +4402,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4301,6 +4532,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index cc468cf..e0b65ba 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", diff --git a/modules/frontend/src/App.css b/modules/frontend/src/App.css index b9d355d..e69de29 100644 --- a/modules/frontend/src/App.css +++ b/modules/frontend/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index f67c37e..909ad6c 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,10 +2,13 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { Header } from "./components/Header/Header"; const App: React.FC = () => { + const username = "nihonium"; return ( <Router> + <Header username={username} /> <Routes> <Route path="/users/:id" element={<UserPage />} /> <Route path="/titles" element={<TitlesPage />} /> diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx new file mode 100644 index 0000000..98b1295 --- /dev/null +++ b/modules/frontend/src/components/Header/Header.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; + +type HeaderProps = { + username?: string; +}; + +export const Header: React.FC<HeaderProps> = ({ username }) => { + const [menuOpen, setMenuOpen] = useState(false); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + return ( + <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex justify-between h-16 items-center"> + + {/* Левый блок — логотип / название */} + <div className="flex-shrink-0"> + <Link to="/" className="text-xl font-bold text-gray-800 hover:text-blue-600"> + NyanimeDB + </Link> + </div> + + {/* Центр — ссылки на разделы (desktop) */} + <nav className="hidden md:flex space-x-4"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600"> + Titles + </Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600"> + Users + </Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600"> + About + </Link> + </nav> + + {/* Правый блок — профиль */} + <div className="hidden md:flex items-center space-x-4"> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium"> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium"> + Login + </Link> + )} + </div> + + {/* Бургер для мобильного */} + <div className="md:hidden flex items-center"> + <button + onClick={toggleMenu} + className="p-2 rounded-md hover:bg-gray-200 transition" + > + {menuOpen ? ( + <XMarkIcon className="w-6 h-6 text-gray-800" /> + ) : ( + <Bars3Icon className="w-6 h-6 text-gray-800" /> + )} + </button> + </div> + + </div> + </div> + + {/* Мобильное меню */} + {menuOpen && ( + <div className="md:hidden bg-white border-t border-gray-200 shadow-md"> + <nav className="flex flex-col p-4 space-y-2"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Titles</Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Users</Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>About</Link> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + Login + </Link> + )} + </nav> + </div> + )} + </header> + ); +}; diff --git a/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx new file mode 100644 index 0000000..679feea --- /dev/null +++ b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; + +export type LayoutSwitchProps = { + layout: "square" | "horizontal" + setLayout: (value: React.SetStateAction<"square" | "horizontal">) => void +}; + +export function LayoutSwitch({ + layout, + setLayout +}: LayoutSwitchProps) { + + return ( + <div className="flex justify-end"> + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" + ? <Squares2X2Icon className="w-6 h-6" /> + : <Bars3Icon className="w-6 h-6" /> + } + </button> + </div> + ); +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index e0e8ab9..67488c0 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,103 +1,49 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; -import type { CursorObj } from "../../api"; export type ListViewProps<T> = { - fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + items: T[]; + layout: "square" | "horizontal"; renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; - pageSize?: number; - searchPlaceholder?: string; - setSearch: any; + onLoadMore: () => void; + hasMore: boolean; + loadingMore: boolean; }; export function ListView<T>({ - fetchItems, + items, + layout, renderItem, - pageSize = 20, - searchPlaceholder = "Search...", + onLoadMore, + hasMore, + loadingMore }: ListViewProps<T>) { - const [items, setItems] = useState<T[]>([]); - const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); - const [error, setError] = useState<string | null>(null); - - const loadItems = async (reset: boolean = false) => { - try { - if (reset) { - setLoading(true); - setCursorObj(undefined); - } else { - setLoadingMore(true); - } - - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" - console.log("encoded cursor: " + cursorStr) - - const result = await fetchItems(cursorStr, pageSize); - - if (reset) setItems(result.items); - else setItems(prev => [...prev, ...result.items]); - - setCursorObj(result.cursor); - setError(null); - } catch (err: any) { - console.error(err); - setError("Failed to fetch items."); - } finally { - setLoading(false); - setLoadingMore(false); - } - }; - - useEffect(() => { - loadItems(true); - }, [search]); return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - <div className="w-full sm:w-4/5 flex gap-4 mb-8"> - <input - type="text" - placeholder={searchPlaceholder} - // value={search} - onChange={e => setSearch(e.target.value)} - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" - /> - <button - className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" - onClick={() => - setLayout(prev => (prev === "square" ? "horizontal" : "square")) - }> - {layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} - </button> - </div> - - {error && <div className="text-red-600 mb-6 font-medium">{error}</div>} - + <div className="w-full flex flex-col items-center"> + {/* Items */} <div className={`w-full sm:w-4/5 grid gap-6 ${ - layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" + layout === "square" + ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + : "grid-cols-1" }`} > {items.map(item => renderItem(item, layout))} </div> - {cursorObj && ( - <div className="mt-8 flex justify-center w-full sm:w-4/5"> + {/* Load More */} + {hasMore && ( + <div className="mt-8"> <button - className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed" - onClick={() => loadItems(false)} + className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50" disabled={loadingMore} + onClick={onLoadMore} > {loadingMore ? "Loading..." : "Load More"} </button> </div> )} - - {loading && <div className="mt-20 font-medium">Loading...</div>} </div> ); } diff --git a/modules/frontend/src/components/SearchBar/SearchBar.tsx b/modules/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..87aee66 --- /dev/null +++ b/modules/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,34 @@ +type SearchBarProps = { + placeholder?: string; + search: string; + setSearch: (value: string) => void; +}; + +export function SearchBar({ + placeholder = "Search...", + search, + setSearch, +}: SearchBarProps) { + return ( + <div className="w-full"> + <input + type="text" + value={search} + placeholder={placeholder} + onChange={(e) => setSearch(e.target.value)} + className=" + w-full + px-4 + py-2 + border + border-gray-300 + rounded-lg + focus:outline-none + focus:ring-2 + focus:ring-blue-500 + text-black + " + /> + </div> + ); +} diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx new file mode 100644 index 0000000..f1f8195 --- /dev/null +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import type { TitleSort } from "../../api"; +import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; + +type TitlesSortBoxProps = { + sort: TitleSort; + setSort: (value: TitleSort) => void; + sortForward: boolean; + setSortForward: (value: boolean) => void; +}; + +const SORT_OPTIONS: TitleSort[] = ["id", "rating", "year", "views"]; + +export function TitlesSortBox({ + sort, + setSort, + sortForward, + setSortForward, +}: TitlesSortBoxProps) { + const [open, setOpen] = useState(false); + + const toggleSortDirection = () => setSortForward(!sortForward); + const handleSortSelect = (newSort: TitleSort) => { + setSort(newSort); + setOpen(false); + }; + + return ( + <div className="inline-flex relative z-50"> + {/* Левая часть — смена направления */} + <button + onClick={toggleSortDirection} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-l-lg transition" + > + {sortForward ? <ArrowUpIcon className="w-4 h-4 mr-1" /> : <ArrowDownIcon className="w-4 h-4 mr-1" />} + <span className="text-sm font-medium">Order</span> + </button> + + {/* Правая часть — выбор параметра */} + <button + onClick={() => setOpen(!open)} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 border-l-0 rounded-r-lg transition" + > + <span className="text-sm font-medium">{sort}</span> + <ChevronDownIcon className="w-4 h-4 ml-1" /> + </button> + + {/* Dropdown */} + {open && ( + <ul className="absolute top-full left-0 mt-1 w-40 bg-white border border-gray-300 rounded-md shadow-lg z-[1000]"> + {SORT_OPTIONS.map(option => ( + <li key={option}> + <button + className={`w-full text-left px-4 py-2 hover:bg-gray-100 transition ${ + option === sort ? "font-bold bg-gray-100" : "" + }`} + onClick={() => handleSortSelect(option)} + > + {option} + </button> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index e20de02..7b32a9b 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -5,4 +5,5 @@ html, body, #root { padding: 0; width: 100%; height: 100%; + @apply text-black bg-white; } \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 4aeb5ec..0fec3c8 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,52 +1,154 @@ +import { useEffect, useState } from "react"; import { ListView } from "../../components/ListView/ListView"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { Title } from "../../api"; -import { useState } from "react"; +import type { CursorObj, Title, TitleSort } from "../../api"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; + +const PAGE_SIZE = 10; -const PAGE_SIZE = 20; export default function TitlesPage() { + const [titles, setTitles] = useState<Title[]>([]); + const [nextPage, setNextPage] = useState<Title[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); - const loadTitles = async (cursor: string, limit: number) => { - const result = await DefaultService.getTitles( - cursor, - undefined, - true, - search, - undefined, - undefined, - undefined, - undefined, - limit, - undefined, - 'all' - ); + const fetchPage = async (cursorObj: CursorObj | null) => { + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; - return { - items: result.data ?? [], - cursor: result.cursor ?? null, - }; + try { + const result = await DefaultService.getTitles( + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, + undefined, + undefined, + undefined, + PAGE_SIZE, + undefined, + "all" + ); + + if ((result === undefined) || !result.data?.length) { + return { items: [], nextCursor: null }; + } + return { + items: result.data ?? [], + nextCursor: result.cursor ?? null + }; + } catch (err: any) { + if (err.status === 204) { + return { items: [], nextCursor: null }; + } + throw err; + } }; - return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - - <h1 className="text-4xl font-bold mb-6 text-center">Titles</h1> + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoading(true); + setTitles([]); + setNextPage([]); + setCursor(null); - <ListView<Title> - pageSize={PAGE_SIZE} - fetchItems={loadTitles} - searchPlaceholder="Search titles..." - renderItem={(title, layout) => - layout === "square" - ? <TitleCardSquare title={title} /> - : <TitleCardHorizontal title={title} /> - } - setSearch={setSearch} - /> + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoading(false); + }; + + initLoad(); + }, [search, sort, sortForward]); + + +const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + // Подгружаем следующую страницу с сервера + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) { + setNextPage(next.items); + } + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + // Любой сценарий – выключаем loadingMore + setLoadingMore(false); +}; + + + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center text-black">Titles</h1> + + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox + sort={sort} + setSort={setSort} + sortForward={sortForward} + setSortForward={setSortForward} + /> + </div> + + {loading && <div className="mt-20 font-medium text-black">Loading...</div>} + + {!loading && titles.length === 0 && ( + <div className="mt-20 font-medium text-black">No titles found.</div> + )} + + {titles.length > 0 && ( + <> + <ListView<Title> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length == 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} </div> ); } - diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 6c261e6..554d630 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ tailwindcss() ], server: { - host: '127.0.0.1', + host: '0.0.0.0', port: 8083, }, }) From 89a05492c350433ee7d83a6256f5cca9e0fe2b06 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 06:34:07 +0300 Subject: [PATCH 55/97] refact: optimizied queries --- api/_build/openapi.yaml | 7 +- api/api.gen.go | 14 ++- api/paths/titles.yaml | 8 +- modules/backend/handlers/titles.go | 177 ++++++++++++++++++----------- modules/backend/queries.sql | 91 ++++++++++----- sql/queries.sql.go | 140 ++++++++++++++++------- 6 files changed, 294 insertions(+), 143 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 5ff77e0..c059166 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -23,7 +23,12 @@ paths: - in: query name: status schema: - $ref: '#/components/schemas/TitleStatus' + type: array + items: + $ref: '#/components/schemas/TitleStatus' + description: List of title statuses to filter + style: form + explode: false - in: query name: rating schema: diff --git a/api/api.gen.go b/api/api.gen.go index f252a5a..e56f6b8 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -156,11 +156,13 @@ type Cursor = string // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` - SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` - Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` @@ -638,7 +640,7 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { // ------------- Optional query parameter "status" ------------- - err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) return diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index 3c5cf76..af2d17b 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -15,12 +15,12 @@ get: - in: query name: status schema: - type: array - items: - $ref: '../schemas/enums/TitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' description: List of title statuses to filter style: form - explode: true + explode: false - in: query name: rating diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index aea6037..71547c2 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -20,18 +20,44 @@ func Word2Sqlc(s *string) *string { return s } -func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { +type SqlcStatus struct { + ongoing string + finished string + planned string +} + +func TitleStatus2Sqlc(s []oapi.TitleStatus) (*SqlcStatus, error) { if s == nil { return nil, nil } - var t sqlc.TitleStatusT + var sqlc_status SqlcStatus + for _, t := range s { + switch t { + case oapi.TitleStatusFinished: + sqlc_status.finished = "finished" + case oapi.TitleStatusOngoing: + sqlc_status.ongoing = "ongoing" + case oapi.TitleStatusPlanned: + sqlc_status.planned = "planned" + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return &sqlc_status, nil +} + +func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { + if s == nil { + return nil, nil + } + var t oapi.TitleStatus switch *s { - case oapi.TitleStatusFinished: - t = sqlc.TitleStatusTFinished - case oapi.TitleStatusOngoing: - t = sqlc.TitleStatusTOngoing - case oapi.TitleStatusPlanned: - t = sqlc.TitleStatusTPlanned + case sqlc.TitleStatusTFinished: + t = oapi.TitleStatusFinished + case sqlc.TitleStatusTOngoing: + t = oapi.TitleStatusOngoing + case sqlc.TitleStatusTPlanned: + t = oapi.TitleStatusPlanned default: return nil, fmt.Errorf("unexpected tittle status: %s", *s) } @@ -132,66 +158,74 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { return &oapi_studio, nil } -func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { +func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { - var oapi_title oapi.Title + // var oapi_title oapi.Title - title_names := make(map[string][]string, 1) + title_names := make(map[string][]string, 0) err := json.Unmarshal(title.TitleNames, &title_names) if err != nil { - return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) + return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } - episodes_lens := make(map[string]float64, 1) + episodes_lens := make(map[string]float64, 0) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { - return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) } - oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) + oapi_tag_names := make(oapi.Tags, 0) + err = json.Unmarshal(title.TagNames, &oapi_tag_names) if err != nil { - return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) + return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) } - if oapi_tag_names != nil { - oapi_title.Tags = oapi_tag_names + + var oapi_studio oapi.Studio + + oapi_studio.Id = title.StudioID + if title.StudioName != nil { + oapi_studio.Name = *title.StudioName } + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + oapi_studio.Poster.StorageType = &title.StudioStorageType + } + + var oapi_image oapi.Image if title.PosterID != nil { - oapi_image, err := s.GetImage(ctx, *title.PosterID) - if err != nil { - return oapi_title, fmt.Errorf("GetImage: %v", err) - } - if oapi_image != nil { - oapi_title.Poster = oapi_image - } - } - - oapi_studio, err := s.GetStudio(ctx, title.StudioID) - if err != nil { - return oapi_title, fmt.Errorf("GetStudio: %v", err) - } - if oapi_studio != nil { - oapi_title.Studio = oapi_studio + oapi_image.Id = title.PosterID + oapi_image.ImagePath = title.TitleImagePath + oapi_image.StorageType = &title.TitleStorageType } + var release_season oapi.ReleaseSeason if title.ReleaseSeason != nil { - rs := oapi.ReleaseSeason(*title.ReleaseSeason) - oapi_title.ReleaseSeason = &rs - } else { - oapi_title.ReleaseSeason = nil + release_season = oapi.ReleaseSeason(*title.ReleaseSeason) } - ts := oapi.TitleStatus(title.TitleStatus) - oapi_title.TitleStatus = &ts - - oapi_title.Id = title.ID - oapi_title.Rating = title.Rating - oapi_title.RatingCount = title.RatingCount - oapi_title.ReleaseYear = title.ReleaseYear - oapi_title.TitleNames = title_names - oapi_title.EpisodesAired = title.EpisodesAired - oapi_title.EpisodesAll = title.EpisodesAll - oapi_title.EpisodesLen = &episodes_lens + oapi_status, err := TitleStatus2oapi(&title.TitleStatus) + if err != nil { + return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) + } + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAired, + EpisodesLen: &episodes_lens, + Id: title.ID, + Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: title_names, + TitleStatus: oapi_status, + // AdditionalProperties: + } return oapi_title, nil } @@ -207,8 +241,29 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil } - - oapi_title, err = s.mapTitle(ctx, sqlc_title) + _sqlc_title := sqlc.SearchTitlesRow{ + ID: sqlc_title.ID, + StudioID: sqlc_title.StudioID, + PosterID: sqlc_title.PosterID, + TitleStatus: sqlc_title.TitleStatus, + Rating: sqlc_title.Rating, + RatingCount: sqlc_title.RatingCount, + ReleaseYear: sqlc_title.ReleaseYear, + ReleaseSeason: sqlc_title.ReleaseSeason, + Season: sqlc_title.Season, + EpisodesAired: sqlc_title.EpisodesAired, + EpisodesAll: sqlc_title.EpisodesAll, + EpisodesLen: sqlc_title.EpisodesLen, + TitleStorageType: sqlc_title.TitleStorageType, + TitleImagePath: sqlc_title.TitleImagePath, + TagNames: sqlc_title.TitleNames, + StudioName: sqlc_title.StudioName, + StudioIllustID: sqlc_title.StudioIllustID, + StudioDesc: sqlc_title.StudioDesc, + StudioStorageType: sqlc_title.StudioStorageType, + StudioImagePath: sqlc_title.StudioImagePath, + } + oapi_title, err = s.mapTitle(ctx, _sqlc_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil @@ -220,19 +275,8 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) - // old_cursor := oapi.CursorObj{ - // Id: 1, - // } - - // if request.Params.Cursor != nil { - // if old_cursor, err := parseCursor(*request.Params.Cursor); err != nil { - // log.Errorf("%v", err) - // return oapi.GetTitles400Response{}, err - // } - // } - word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(request.Params.Status) + status, err := TitleStatus2Sqlc(*request.Params.Status) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err @@ -245,7 +289,9 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje params := sqlc.SearchTitlesParams{ Word: word, - Status: status, + Ongoing: status.ongoing, + Finished: status.finished, + Planned: status.planned, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, @@ -254,6 +300,9 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje Limit: request.Params.Limit, } + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } if request.Params.Sort != nil { params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { @@ -289,7 +338,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje if request.Params.Sort != nil { switch string(*request.Params.Sort) { case "year": - tmp := string(*t.ReleaseYear) + tmp := fmt.Sprint("%d", *t.ReleaseYear) new_cursor.Param = &tmp case "rating": tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7118781..16f8120 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -73,14 +73,48 @@ RETURNING id, tag_names; -- WHERE user_id = $1; -- name: GetTitleByID :one -SELECT * -FROM titles -WHERE id = sqlc.arg('title_id')::bigint; +-- sqlc.struct: TitlesFull +SELECT + t.*, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE id = sqlc.arg('title_id')::bigint +GROUP BY + t.id, i.id, s.id, si.id; -- name: SearchTitles :many SELECT - * -FROM titles + t.*, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + WHERE CASE WHEN sqlc.arg('forward')::boolean THEN @@ -88,17 +122,17 @@ WHERE CASE sqlc.arg('sort_by')::text WHEN 'year' THEN (sqlc.narg('cursor_year')::int IS NULL) OR - (release_year > sqlc.narg('cursor_year')::int) OR - (release_year = sqlc.narg('cursor_year')::int AND id > sqlc.narg('cursor_id')::bigint) + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) WHEN 'rating' THEN (sqlc.narg('cursor_rating')::float IS NULL) OR - (rating > sqlc.narg('cursor_rating')::float) OR - (rating = sqlc.narg('cursor_rating')::float AND id > sqlc.narg('cursor_id')::bigint) + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) WHEN 'id' THEN (sqlc.narg('cursor_id')::bigint IS NULL) OR - (id > sqlc.narg('cursor_id')::bigint) + (t.id > sqlc.narg('cursor_id')::bigint) ELSE true -- fallback END @@ -108,17 +142,17 @@ WHERE CASE sqlc.arg('sort_by')::text WHEN 'year' THEN (sqlc.narg('cursor_year')::int IS NULL) OR - (release_year < sqlc.narg('cursor_year')::int) OR - (release_year = sqlc.narg('cursor_year')::int AND id < sqlc.narg('cursor_id')::bigint) + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) WHEN 'rating' THEN (sqlc.narg('cursor_rating')::float IS NULL) OR - (rating < sqlc.narg('cursor_rating')::float) OR - (rating = sqlc.narg('cursor_rating')::float AND id < sqlc.narg('cursor_id')::bigint) + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) WHEN 'id' THEN (sqlc.narg('cursor_id')::bigint IS NULL) OR - (id < sqlc.narg('cursor_id')::bigint) + (t.id < sqlc.narg('cursor_id')::bigint) ELSE true END @@ -131,7 +165,7 @@ WHERE SELECT bool_and( EXISTS ( SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) + FROM jsonb_each_text(t.title_names) AS t(key, val) WHERE val ILIKE pattern ) ) @@ -147,28 +181,31 @@ WHERE END ) - AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) - AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) - AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) - AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) + AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + +GROUP BY + t.id, i.id, s.id, si.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN CASE - WHEN sqlc.arg('sort_by')::text = 'id' THEN id - WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year - WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating END END ASC, CASE WHEN NOT sqlc.arg('forward')::boolean THEN CASE - WHEN sqlc.arg('sort_by')::text = 'id' THEN id - WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year - WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating END END DESC, - CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN id END ASC + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit diff --git a/sql/queries.sql.go b/sql/queries.sql.go index c043f8a..4342a12 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -116,11 +116,53 @@ const getTitleByID = `-- name: GetTitleByID :one -SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM titles +SELECT + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + WHERE id = $1::bigint +GROUP BY + t.id, i.id, s.id, si.id ` +type GetTitleByIDRow struct { + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType string `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + // -- name: ListUsers :many // SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date // FROM users @@ -144,9 +186,10 @@ WHERE id = $1::bigint // -- name: DeleteUser :exec // DELETE FROM users // WHERE user_id = $1; -func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error) { +// sqlc.struct: TitlesFull +func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByIDRow, error) { row := q.db.QueryRow(ctx, getTitleByID, titleID) - var i Title + var i GetTitleByIDRow err := row.Scan( &i.ID, &i.TitleNames, @@ -161,6 +204,14 @@ func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error &i.EpisodesAired, &i.EpisodesAll, &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, ) return i, err } @@ -288,15 +339,15 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams const searchTitles = `-- name: SearchTitles :many SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - g.tag_names as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + FROM titles as t LEFT JOIN images as i ON (t.image_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) @@ -370,10 +421,13 @@ WHERE END ) - AND ($7::title_status_t IS NULL OR t.title_status = $7::title_status_t) - AND ($8::float IS NULL OR t.rating >= $8::float) - AND ($9::int IS NULL OR t.release_year = $9::int) - AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + AND (t.title_status::text IN ($7::text, $8::text, $9::text)) + AND ($10::float IS NULL OR t.rating >= $10::float) + AND ($11::int IS NULL OR t.release_year = $11::int) + AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + +GROUP BY + t.id, i.id, s.id, si.id ORDER BY CASE WHEN $1::boolean THEN @@ -393,7 +447,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($11::int, 100) +LIMIT COALESCE($13::int, 100) ` type SearchTitlesParams struct { @@ -403,7 +457,9 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - Status *TitleStatusT `json:"status"` + Ongoing string `json:"ongoing"` + Finished string `json:"finished"` + Planned string `json:"planned"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -411,27 +467,27 @@ type SearchTitlesParams struct { } type SearchTitlesRow struct { - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType NullStorageTypeT `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType NullStorageTypeT `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType string `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` } func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { @@ -442,7 +498,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, - arg.Status, + arg.Ongoing, + arg.Finished, + arg.Planned, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, From 6485563a952babf99453957466219e4a4bbf1df1 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 22 Nov 2025 06:37:39 +0300 Subject: [PATCH 56/97] fix: react imports --- modules/frontend/src/components/ListView/ListView.tsx | 3 +-- .../frontend/src/components/TitlesSortBox/TitlesSortBox.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index 67488c0..ea6d683 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,5 +1,4 @@ -import React, { useState } from "react"; -import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; +import React from "react"; export type ListViewProps<T> = { items: T[]; diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx index f1f8195..ddafd34 100644 --- a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useState } from "react"; import type { TitleSort } from "../../api"; import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; From 258eb749d985081312f55f5feca1236c4247b3d3 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:03:02 +0300 Subject: [PATCH 57/97] fix: status handling fixed --- modules/backend/handlers/titles.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 71547c2..84fc87e 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -26,12 +26,12 @@ type SqlcStatus struct { planned string } -func TitleStatus2Sqlc(s []oapi.TitleStatus) (*SqlcStatus, error) { - if s == nil { - return nil, nil - } +func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { var sqlc_status SqlcStatus - for _, t := range s { + if s == nil { + return &sqlc_status, nil + } + for _, t := range *s { switch t { case oapi.TitleStatusFinished: sqlc_status.finished = "finished" @@ -276,11 +276,12 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(*request.Params.Status) + status, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) From ba68b5ee0465fd70e15d229591df9d8dfaacba64 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:09:30 +0300 Subject: [PATCH 58/97] feat: getusertitles now must return all the title info --- api/_build/openapi.yaml | 5 ++--- api/api.gen.go | 18 ++++++++++-------- api/schemas/UserTitle.yaml | 5 ++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index c059166..c07dbbc 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -424,9 +424,8 @@ components: user_id: type: integer format: int64 - title_id: - type: integer - format: int64 + title: + $ref: '#/components/schemas/Title' status: $ref: '#/components/schemas/UserTitleStatus' rate: diff --git a/api/api.gen.go b/api/api.gen.go index e56f6b8..320e7a2 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -143,7 +143,7 @@ type UserTitle struct { // Status User's title status Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` + Title *Title `json:"title,omitempty"` UserId int64 `json:"user_id"` AdditionalProperties map[string]interface{} `json:"-"` } @@ -493,12 +493,12 @@ func (a *UserTitle) UnmarshalJSON(b []byte) error { delete(object, "status") } - if raw, found := object["title_id"]; found { - err = json.Unmarshal(raw, &a.TitleId) + if raw, found := object["title"]; found { + err = json.Unmarshal(raw, &a.Title) if err != nil { - return fmt.Errorf("error reading 'title_id': %w", err) + return fmt.Errorf("error reading 'title': %w", err) } - delete(object, "title_id") + delete(object, "title") } if raw, found := object["user_id"]; found { @@ -554,9 +554,11 @@ func (a UserTitle) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("error marshaling 'status': %w", err) } - object["title_id"], err = json.Marshal(a.TitleId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_id': %w", err) + if a.Title != nil { + object["title"], err = json.Marshal(a.Title) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title': %w", err) + } } object["user_id"], err = json.Marshal(a.UserId) diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index 658e350..3beaec6 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -7,9 +7,8 @@ properties: user_id: type: integer format: int64 - title_id: - type: integer - format: int64 + title: + $ref: ./Title.yaml status: $ref: ./enums/UserTitleStatus.yaml rate: From 79485e04c015412d2fe7ab46cf0e1506009b870e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:23:48 +0300 Subject: [PATCH 59/97] fix: mismatch in colimn name --- sql/queries.sql.go | 312 ++++++++++++++++++++++++--------------------- 1 file changed, 166 insertions(+), 146 deletions(-) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 4342a12..9987e78 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -8,8 +8,6 @@ package sqlc import ( "context" "time" - - "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -43,56 +41,6 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro return i, err } -const getReviewByID = `-- name: GetReviewByID :one - - - -SELECT id, data, rating, user_id, title_id, created_at -FROM reviews -WHERE review_id = $1::bigint -` - -// 100 is default limit -// -- name: ListTitles :many -// SELECT title_id, title_names, studio_id, poster_id, signal_ids, -// -// title_status, rating, rating_count, release_year, release_season, -// season, episodes_aired, episodes_all, episodes_len -// -// FROM titles -// ORDER BY title_id -// LIMIT $1 OFFSET $2; -// -- name: UpdateTitle :one -// UPDATE titles -// SET -// -// title_names = COALESCE(sqlc.narg('title_names'), title_names), -// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), -// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), -// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), -// title_status = COALESCE(sqlc.narg('title_status'), title_status), -// release_year = COALESCE(sqlc.narg('release_year'), release_year), -// release_season = COALESCE(sqlc.narg('release_season'), release_season), -// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), -// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), -// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -// -// WHERE title_id = sqlc.arg('title_id') -// RETURNING *; -func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { - row := q.db.QueryRow(ctx, getReviewByID, reviewID) - var i Review - err := row.Scan( - &i.ID, - &i.Data, - &i.Rating, - &i.UserID, - &i.TitleID, - &i.CreatedAt, - ) - return i, err -} - const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios @@ -128,7 +76,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -349,7 +297,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -548,111 +496,183 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many -SELECT - user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true - END - AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) - AND ($3::float IS NULL OR t.rating >= $3::float) - AND ($4::int IS NULL OR t.release_year = $4::int) - AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) - AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) -LIMIT COALESCE($7::int, 100) + + + + + + + + + + + + + + + + +SELECT id, data, rating, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint ` -type SearchUserTitlesParams struct { - Word *string `json:"word"` - Status *TitleStatusT `json:"status"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` - Limit *int32 `json:"limit"` -} - -type SearchUserTitlesRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` -} - // 100 is default limit -func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { - rows, err := q.db.Query(ctx, searchUserTitles, - arg.Word, - arg.Status, - arg.Rating, - arg.ReleaseYear, - arg.ReleaseSeason, - arg.UsertitleStatus, - arg.Limit, - ) +// SELECT +// +// t.*, +// u.user_id as user_id, +// u.status as usertitle_status, +// u.rate as user_rate, +// u.review_id as review_id, +// u.ctime as user_ctime, +// i.storage_type::text as title_storage_type, +// i.image_path as title_image_path, +// jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, +// s.studio_name as studio_name, +// s.illust_id as studio_illust_id, +// s.studio_desc as studio_desc, +// si.storage_type::text as studio_storage_type, +// si.image_path as studio_image_path +// +// FROM usertitles as u +// LEFT JOIN titles as t ON (u.title_id = t.id) +// LEFT JOIN images as i ON (t.poster_id = i.id) +// LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +// LEFT JOIN tags as g ON (tt.tag_id = g.id) +// LEFT JOIN studios as s ON (t.studio_id = s.id) +// LEFT JOIN images as si ON (s.illust_id = si.id) +// WHERE +// +// CASE +// WHEN sqlc.arg('forward')::boolean THEN +// -- forward: greater than cursor (next page) +// CASE sqlc.arg('sort_by')::text +// WHEN 'year' THEN +// (sqlc.narg('cursor_year')::int IS NULL) OR +// (t.release_year > sqlc.narg('cursor_year')::int) OR +// (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) +// WHEN 'rating' THEN +// (sqlc.narg('cursor_rating')::float IS NULL) OR +// (t.rating > sqlc.narg('cursor_rating')::float) OR +// (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) +// WHEN 'id' THEN +// (sqlc.narg('cursor_id')::bigint IS NULL) OR +// (t.id > sqlc.narg('cursor_id')::bigint) +// ELSE true -- fallback +// END +// ELSE +// -- backward: less than cursor (prev page) +// CASE sqlc.arg('sort_by')::text +// WHEN 'year' THEN +// (sqlc.narg('cursor_year')::int IS NULL) OR +// (t.release_year < sqlc.narg('cursor_year')::int) OR +// (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) +// WHEN 'rating' THEN +// (sqlc.narg('cursor_rating')::float IS NULL) OR +// (t.rating < sqlc.narg('cursor_rating')::float) OR +// (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) +// WHEN 'id' THEN +// (sqlc.narg('cursor_id')::bigint IS NULL) OR +// (t.id < sqlc.narg('cursor_id')::bigint) +// ELSE true +// END +// END +// AND ( +// CASE +// WHEN sqlc.narg('word')::text IS NOT NULL THEN +// ( +// SELECT bool_and( +// EXISTS ( +// SELECT 1 +// FROM jsonb_each_text(t.title_names) AS t(key, val) +// WHERE val ILIKE pattern +// ) +// ) +// FROM unnest( +// ARRAY( +// SELECT '%' || trim(w) || '%' +// FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w +// WHERE trim(w) <> '' +// ) +// ) AS pattern +// ) +// ELSE true +// END +// ) +// AND (u.::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) +// AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) +// AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) +// AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) +// AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) +// +// GROUP BY +// +// t.id, i.id, s.id, si.id +// +// ORDER BY +// +// CASE WHEN sqlc.arg('forward')::boolean THEN +// CASE +// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id +// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year +// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating +// END +// END ASC, +// CASE WHEN NOT sqlc.arg('forward')::boolean THEN +// CASE +// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id +// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year +// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating +// END +// END DESC, +// CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC +// +// LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) SearchUserTitles(ctx context.Context, reviewID int64) ([]Review, error) { + rows, err := q.db.Query(ctx, searchUserTitles, reviewID) if err != nil { return nil, err } defer rows.Close() - items := []SearchUserTitlesRow{} + items := []Review{} for rows.Next() { - var i SearchUserTitlesRow + var i Review if err := rows.Scan( + &i.ID, + &i.Data, + &i.Rating, &i.UserID, &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - &i.Ctime, - &i.ID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, + &i.CreatedAt, ); err != nil { return nil, err } From 32566fe7a2cc5d2469f9f47c1dbd55f35859d18f Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:53:50 +0300 Subject: [PATCH 60/97] feat: query for usertitles written --- api/_build/openapi.yaml | 7 +- api/api.gen.go | 10 +- api/paths/users-id-titles.yaml | 7 +- modules/backend/handlers/common.go | 117 +++++++------- modules/backend/handlers/titles.go | 72 --------- modules/backend/handlers/users.go | 138 +++++++++++++--- modules/backend/queries.sql | 144 +++++++++++++---- sql/queries.sql.go | 252 +++++++++++++++++++++-------- 8 files changed, 497 insertions(+), 250 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index c07dbbc..722b7af 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -158,7 +158,12 @@ paths: - in: query name: status schema: - $ref: '#/components/schemas/TitleStatus' + type: array + items: + $ref: '#/components/schemas/TitleStatus' + description: List of title statuses to filter + style: form + explode: false - in: query name: watch_status schema: diff --git a/api/api.gen.go b/api/api.gen.go index 320e7a2..54c8fc1 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -183,9 +183,11 @@ type GetUsersUserIdParams struct { // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` - Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` @@ -811,7 +813,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { // ------------- Optional query parameter "status" ------------- - err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) return diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0cde5af..0788319 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -14,7 +14,12 @@ get: - in: query name: status schema: - $ref: '../schemas/enums/TitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: false - in: query name: watch_status schema: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 3d61b91..6618d49 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -1,6 +1,10 @@ package handlers import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" sqlc "nyanimedb/sql" "strconv" ) @@ -13,70 +17,75 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } -// type Cursor interface { -// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) +func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { -// Values() map[string]interface{} -// // for logs only -// Type() string -// } + title_names := make(map[string][]string, 0) + err := json.Unmarshal(title.TitleNames, &title_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) + } -// type CursorByID struct { -// ID int64 -// } + episodes_lens := make(map[string]float64, 0) + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } -// func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) { -// var cur CursorByID -// if err := json.Unmarshal(data, &cur); err != nil { -// return nil, fmt.Errorf("invalid cursor (id): %w", err) -// } -// if cur.ID == 0 { -// return nil, errors.New("cursor id must be non-zero") -// } -// return cur, nil -// } + oapi_tag_names := make(oapi.Tags, 0) + err = json.Unmarshal(title.TagNames, &oapi_tag_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) + } -// func (c CursorByID) Values() map[string]interface{} { -// return map[string]interface{}{ -// "cursor_id": c.ID, -// "cursor_year": nil, -// "cursor_rating": nil, -// } -// } + var oapi_studio oapi.Studio -// func (c CursorByID) Type() string { return "id" } + oapi_studio.Id = title.StudioID + if title.StudioName != nil { + oapi_studio.Name = *title.StudioName + } + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + oapi_studio.Poster.StorageType = &title.StudioStorageType + } -// func NewCursor(sortBy string) (Cursor, error) { -// switch Type(sortBy) { -// case TypeID: -// return CursorByID{}, nil -// case TypeYear: -// return CursorByYear{}, nil -// case TypeRating: -// return CursorByRating{}, nil -// default: -// return nil, fmt.Errorf("unsupported sort_by: %q", sortBy) -// } -// } + var oapi_image oapi.Image -// decodes a base64-encoded JSON string into a CursorObj -// Returns the parsed CursorObj and an error -// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) { + if title.PosterID != nil { + oapi_image.Id = title.PosterID + oapi_image.ImagePath = title.TitleImagePath + oapi_image.StorageType = &title.TitleStorageType + } -// // Decode base64 -// decoded, err := base64.StdEncoding.DecodeString(encoded) -// if err != nil { -// return nil, fmt.Errorf("parseCursor: %v", err) -// } + var release_season oapi.ReleaseSeason + if title.ReleaseSeason != nil { + release_season = oapi.ReleaseSeason(*title.ReleaseSeason) + } -// // Parse JSON -// var cursor oapi.CursorObj -// if err := json.Unmarshal(decoded, &cursor); err != nil { -// return nil, fmt.Errorf("parseCursor: %v", err) -// } + oapi_status, err := TitleStatus2oapi(&title.TitleStatus) + if err != nil { + return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) + } + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAired, + EpisodesLen: &episodes_lens, + Id: title.ID, + Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: title_names, + TitleStatus: oapi_status, + // AdditionalProperties: + } -// return &cursor, nil -// } + return oapi_title, nil +} func parseInt64(s string) (int32, error) { i, err := strconv.ParseInt(s, 10, 64) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 84fc87e..332a4b6 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -158,78 +158,6 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { return &oapi_studio, nil } -func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { - - // var oapi_title oapi.Title - - title_names := make(map[string][]string, 0) - err := json.Unmarshal(title.TitleNames, &title_names) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) - } - - episodes_lens := make(map[string]float64, 0) - err = json.Unmarshal(title.EpisodesLen, &episodes_lens) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) - } - - oapi_tag_names := make(oapi.Tags, 0) - err = json.Unmarshal(title.TagNames, &oapi_tag_names) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) - } - - var oapi_studio oapi.Studio - - oapi_studio.Id = title.StudioID - if title.StudioName != nil { - oapi_studio.Name = *title.StudioName - } - oapi_studio.Description = title.StudioDesc - if title.StudioIllustID != nil { - oapi_studio.Poster.Id = title.StudioIllustID - oapi_studio.Poster.ImagePath = title.StudioImagePath - oapi_studio.Poster.StorageType = &title.StudioStorageType - } - - var oapi_image oapi.Image - - if title.PosterID != nil { - oapi_image.Id = title.PosterID - oapi_image.ImagePath = title.TitleImagePath - oapi_image.StorageType = &title.TitleStorageType - } - - var release_season oapi.ReleaseSeason - if title.ReleaseSeason != nil { - release_season = oapi.ReleaseSeason(*title.ReleaseSeason) - } - - oapi_status, err := TitleStatus2oapi(&title.TitleStatus) - if err != nil { - return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) - } - oapi_title := oapi.Title{ - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAired, - EpisodesLen: &episodes_lens, - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: &release_season, - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: title_names, - TitleStatus: oapi_status, - // AdditionalProperties: - } - - return oapi_title, nil -} - func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { var oapi_title oapi.Title diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 0fa903f..0420b91 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -2,12 +2,15 @@ package handlers import ( "context" + "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/oapi-codegen/runtime/types" + log "github.com/sirupsen/logrus" ) // type Server struct { @@ -50,33 +53,120 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil } +func sqlDate2oapi(p_date pgtype.Timestamptz) (time.Time, error) { + return time.Time{}, nil +} + +type SqlcUserStatus struct { + dropped string + finished string + planned string + in_progress string +} + +func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { + var sqlc_status SqlcUserStatus + if s == nil { + return &sqlc_status, nil + } + for _, t := range *s { + switch t { + case oapi.UserTitleStatusFinished: + sqlc_status.finished = "finished" + case oapi.UserTitleStatusDropped: + sqlc_status.dropped = "dropped" + case oapi.UserTitleStatusPlanned: + sqlc_status.planned = "planned" + case oapi.UserTitleStatusInProgress: + sqlc_status.in_progress = "in-progress" + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return &sqlc_status, nil +} + func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { - var rate int32 = 9 - var review_id int64 = 3 - time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) + oapi_usertitles := make([]oapi.UserTitle, 0) - var userTitles = []oapi.UserTitle{ - { - UserId: 101, - TitleId: 2001, - Status: oapi.UserTitleStatusFinished, - Rate: &rate, - Ctime: &time, - }, - { - UserId: 102, - TitleId: 2002, - Status: oapi.UserTitleStatusInProgress, - ReviewId: &review_id, - Ctime: &time, - }, - { - UserId: 103, - TitleId: 2003, - Status: oapi.UserTitleStatusDropped, - Ctime: &time, - }, + word := Word2Sqlc(request.Params.Word) + status, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err + } + + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err + } + + // Forward bool `json:"forward"` + // SortBy string `json:"sort_by"` + // CursorYear *int32 `json:"cursor_year"` + // CursorID *int64 `json:"cursor_id"` + // CursorRating *float64 `json:"cursor_rating"` + // Word *string `json:"word"` + // Ongoing string `json:"ongoing"` + // Planned string `json:"planned"` + // Dropped string `json:"dropped"` + // InProgress string `json:"in-progress"` + // Finished string `json:"finished"` + // Rate *int32 `json:"rate"` + // Rating *float64 `json:"rating"` + // ReleaseYear *int32 `json:"release_year"` + // ReleaseSeason *ReleaseSeasonT `json:"release_season"` + // Limit *int32 `json:"limit"` + params := sqlc.SearchTitlesParams{ + Word: word, + Ongoing: status.ongoing, + Finished: status.finished, + Planned: status.planned, + Rating: request.Params.Rating, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Forward: true, + SortBy: "id", + Limit: request.Params.Limit, + } + + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } + if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) + if request.Params.Cursor != nil { + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, nil + } + } + } + + _sqlc_title := sqlc.SearchTitlesRow{ + ID: sqlc_title.ID, + StudioID: sqlc_title.StudioID, + PosterID: sqlc_title.PosterID, + TitleStatus: sqlc_title.TitleStatus, + Rating: sqlc_title.Rating, + RatingCount: sqlc_title.RatingCount, + ReleaseYear: sqlc_title.ReleaseYear, + ReleaseSeason: sqlc_title.ReleaseSeason, + Season: sqlc_title.Season, + EpisodesAired: sqlc_title.EpisodesAired, + EpisodesAll: sqlc_title.EpisodesAll, + EpisodesLen: sqlc_title.EpisodesLen, + TitleStorageType: sqlc_title.TitleStorageType, + TitleImagePath: sqlc_title.TitleImagePath, + TagNames: sqlc_title.TitleNames, + StudioName: sqlc_title.StudioName, + StudioIllustID: sqlc_title.StudioIllustID, + StudioDesc: sqlc_title.StudioDesc, + StudioStorageType: sqlc_title.StudioStorageType, + StudioImagePath: sqlc_title.StudioImagePath, } return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 16f8120..b8cb8aa 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -86,7 +86,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -109,7 +109,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -210,37 +210,125 @@ ORDER BY LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- name: SearchUserTitles :many -SELECT - * -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN sqlc.narg('word')::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +SELECT + t.*, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as u +LEFT JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END END - AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t) + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + + AND (u.status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('planned')::text, sqlc.arg('dropped')::text, sqlc.arg('in-progress')::text)) + AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) - AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t) + +GROUP BY + t.id, i.id, s.id, si.id + +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate + END + END ASC, + CASE WHEN NOT sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate + END + END DESC, + + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 4342a12..2a90265 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -128,7 +128,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -349,7 +349,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -548,82 +548,195 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many -SELECT - user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +SELECT + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as u +LEFT JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE + CASE + WHEN $1::boolean THEN + -- forward: greater than cursor (next page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year > $3::int) OR + (t.release_year = $3::int AND t.id > $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating > $5::float) OR + (t.rating = $5::float AND t.id > $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id > $4::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year < $3::int) OR + (t.release_year = $3::int AND t.id < $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating < $5::float) OR + (t.rating = $5::float AND t.id < $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id < $4::bigint) + + ELSE true + END END - AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) - AND ($3::float IS NULL OR t.rating >= $3::float) - AND ($4::int IS NULL OR t.release_year = $4::int) - AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) - AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) + AND ( + CASE + WHEN $6::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($6::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) -LIMIT COALESCE($7::int, 100) + AND (u.status::text IN ($7::text, $8::text, $9::text, $10::text)) + AND (t.title_status::text IN ($7::text, $11::text, $8::text)) + AND ($12::int IS NULL OR u.rate >= $12::int) + AND ($13::float IS NULL OR t.rating >= $13::float) + AND ($14::int IS NULL OR t.release_year = $14::int) + AND ($15::release_season_t IS NULL OR t.release_season = $15::release_season_t) + +GROUP BY + t.id, i.id, s.id, si.id + +ORDER BY + CASE WHEN $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating + WHEN $2::text = 'rate' THEN u.rate + END + END ASC, + CASE WHEN NOT $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating + WHEN $2::text = 'rate' THEN u.rate + END + END DESC, + + CASE WHEN $2::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE($16::int, 100) ` type SearchUserTitlesParams struct { - Word *string `json:"word"` - Status *TitleStatusT `json:"status"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + Ongoing string `json:"ongoing"` + Planned string `json:"planned"` + Dropped string `json:"dropped"` + InProgress string `json:"in-progress"` + Finished string `json:"finished"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` + ID *int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID *int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus *TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime pgtype.Timestamptz `json:"user_ctime"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType string `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` } // 100 is default limit func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, arg.Word, - arg.Status, + arg.Ongoing, + arg.Planned, + arg.Dropped, + arg.InProgress, + arg.Finished, + arg.Rate, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.UsertitleStatus, arg.Limit, ) if err != nil { @@ -634,12 +747,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara for rows.Next() { var i SearchUserTitlesRow if err := rows.Scan( - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - &i.Ctime, &i.ID, &i.TitleNames, &i.StudioID, @@ -653,6 +760,19 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.EpisodesAired, &i.EpisodesAll, &i.EpisodesLen, + &i.UserID, + &i.UsertitleStatus, + &i.UserRate, + &i.ReviewID, + &i.UserCtime, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, ); err != nil { return nil, err } From 6fa2ff2eb8d73449e585f88cc58997818ff5e39e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:01:46 +0300 Subject: [PATCH 61/97] fix: minor fix to query --- modules/backend/queries.sql | 4 +- sql/queries.sql.go | 316 +++++++++++++++++------------------- 2 files changed, 150 insertions(+), 170 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 16f8120..42b99d1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -78,7 +78,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -101,7 +101,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9987e78..c67033c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -8,6 +8,8 @@ package sqlc import ( "context" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -41,6 +43,56 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro return i, err } +const getReviewByID = `-- name: GetReviewByID :one + + + +SELECT id, data, rating, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint +` + +// 100 is default limit +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Review + err := row.Scan( + &i.ID, + &i.Data, + &i.Rating, + &i.UserID, + &i.TitleID, + &i.CreatedAt, + ) + return i, err +} + const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios @@ -68,7 +120,7 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -76,7 +128,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN images as i ON (t.image_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -289,7 +341,7 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -297,7 +349,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN images as i ON (t.image_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -496,183 +548,111 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many +SELECT + user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) + AND ($3::float IS NULL OR t.rating >= $3::float) + AND ($4::int IS NULL OR t.release_year = $4::int) + AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) + AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) - - - - - - - - - - - - - - - - -SELECT id, data, rating, user_id, title_id, created_at -FROM reviews -WHERE review_id = $1::bigint +LIMIT COALESCE($7::int, 100) ` +type SearchUserTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` + Limit *int32 `json:"limit"` +} + +type SearchUserTitlesRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` +} + // 100 is default limit -// SELECT -// -// t.*, -// u.user_id as user_id, -// u.status as usertitle_status, -// u.rate as user_rate, -// u.review_id as review_id, -// u.ctime as user_ctime, -// i.storage_type::text as title_storage_type, -// i.image_path as title_image_path, -// jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, -// s.studio_name as studio_name, -// s.illust_id as studio_illust_id, -// s.studio_desc as studio_desc, -// si.storage_type::text as studio_storage_type, -// si.image_path as studio_image_path -// -// FROM usertitles as u -// LEFT JOIN titles as t ON (u.title_id = t.id) -// LEFT JOIN images as i ON (t.poster_id = i.id) -// LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -// LEFT JOIN tags as g ON (tt.tag_id = g.id) -// LEFT JOIN studios as s ON (t.studio_id = s.id) -// LEFT JOIN images as si ON (s.illust_id = si.id) -// WHERE -// -// CASE -// WHEN sqlc.arg('forward')::boolean THEN -// -- forward: greater than cursor (next page) -// CASE sqlc.arg('sort_by')::text -// WHEN 'year' THEN -// (sqlc.narg('cursor_year')::int IS NULL) OR -// (t.release_year > sqlc.narg('cursor_year')::int) OR -// (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) -// WHEN 'rating' THEN -// (sqlc.narg('cursor_rating')::float IS NULL) OR -// (t.rating > sqlc.narg('cursor_rating')::float) OR -// (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) -// WHEN 'id' THEN -// (sqlc.narg('cursor_id')::bigint IS NULL) OR -// (t.id > sqlc.narg('cursor_id')::bigint) -// ELSE true -- fallback -// END -// ELSE -// -- backward: less than cursor (prev page) -// CASE sqlc.arg('sort_by')::text -// WHEN 'year' THEN -// (sqlc.narg('cursor_year')::int IS NULL) OR -// (t.release_year < sqlc.narg('cursor_year')::int) OR -// (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) -// WHEN 'rating' THEN -// (sqlc.narg('cursor_rating')::float IS NULL) OR -// (t.rating < sqlc.narg('cursor_rating')::float) OR -// (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) -// WHEN 'id' THEN -// (sqlc.narg('cursor_id')::bigint IS NULL) OR -// (t.id < sqlc.narg('cursor_id')::bigint) -// ELSE true -// END -// END -// AND ( -// CASE -// WHEN sqlc.narg('word')::text IS NOT NULL THEN -// ( -// SELECT bool_and( -// EXISTS ( -// SELECT 1 -// FROM jsonb_each_text(t.title_names) AS t(key, val) -// WHERE val ILIKE pattern -// ) -// ) -// FROM unnest( -// ARRAY( -// SELECT '%' || trim(w) || '%' -// FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w -// WHERE trim(w) <> '' -// ) -// ) AS pattern -// ) -// ELSE true -// END -// ) -// AND (u.::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) -// AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) -// AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) -// AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) -// AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) -// -// GROUP BY -// -// t.id, i.id, s.id, si.id -// -// ORDER BY -// -// CASE WHEN sqlc.arg('forward')::boolean THEN -// CASE -// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id -// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year -// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating -// END -// END ASC, -// CASE WHEN NOT sqlc.arg('forward')::boolean THEN -// CASE -// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id -// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year -// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating -// END -// END DESC, -// CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC -// -// LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -// -- name: ListTitles :many -// SELECT title_id, title_names, studio_id, poster_id, signal_ids, -// -// title_status, rating, rating_count, release_year, release_season, -// season, episodes_aired, episodes_all, episodes_len -// -// FROM titles -// ORDER BY title_id -// LIMIT $1 OFFSET $2; -// -- name: UpdateTitle :one -// UPDATE titles -// SET -// -// title_names = COALESCE(sqlc.narg('title_names'), title_names), -// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), -// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), -// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), -// title_status = COALESCE(sqlc.narg('title_status'), title_status), -// release_year = COALESCE(sqlc.narg('release_year'), release_year), -// release_season = COALESCE(sqlc.narg('release_season'), release_season), -// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), -// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), -// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -// -// WHERE title_id = sqlc.arg('title_id') -// RETURNING *; -func (q *Queries) SearchUserTitles(ctx context.Context, reviewID int64) ([]Review, error) { - rows, err := q.db.Query(ctx, searchUserTitles, reviewID) +func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { + rows, err := q.db.Query(ctx, searchUserTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.UsertitleStatus, + arg.Limit, + ) if err != nil { return nil, err } defer rows.Close() - items := []Review{} + items := []SearchUserTitlesRow{} for rows.Next() { - var i Review + var i SearchUserTitlesRow if err := rows.Scan( - &i.ID, - &i.Data, - &i.Rating, &i.UserID, &i.TitleID, - &i.CreatedAt, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, ); err != nil { return nil, err } From 6a39d89ef99abb9d137bc3e5918615916ec5966b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:13:07 +0300 Subject: [PATCH 62/97] fix --- modules/backend/queries.sql | 4 ++-- sql/queries.sql.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 42b99d1..9bb7c36 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -86,7 +86,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -109,7 +109,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index c67033c..dc8f3f6 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -128,7 +128,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -349,7 +349,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) From f1ba15d3a4e4ccaf68d8db535d03560b191ac7db Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:32:13 +0300 Subject: [PATCH 63/97] fix --- modules/backend/queries.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b8cb8aa..a16dcbc 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -78,7 +78,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -101,7 +101,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -219,7 +219,7 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, From 4b1ac9177dc2795499af9ba5345875a4424ebb59 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:34:26 +0300 Subject: [PATCH 64/97] fix --- modules/backend/queries.sql | 10 ++++++++-- sql/queries.sql.go | 14 ++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 9bb7c36..3c5c10e 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -78,7 +78,10 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -101,7 +104,10 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, diff --git a/sql/queries.sql.go b/sql/queries.sql.go index dc8f3f6..7caa7f0 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -120,7 +120,10 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -155,7 +158,7 @@ type GetTitleByIDRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` + TagNames interface{} `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -341,7 +344,10 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -482,7 +488,7 @@ type SearchTitlesRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` + TagNames interface{} `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` From d0c3547ef6aff355045d2cafdb9eb6687206577e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:47:01 +0300 Subject: [PATCH 65/97] fix --- modules/backend/queries.sql | 4 ++-- sql/models.go | 17 +++++++++-------- sql/queries.sql.go | 23 ++++++++++++----------- sql/sqlc.yaml | 2 ++ 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 3c5c10e..8546271 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -81,7 +81,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -107,7 +107,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, diff --git a/sql/models.go b/sql/models.go index 93cecca..36d4c07 100644 --- a/sql/models.go +++ b/sql/models.go @@ -6,6 +6,7 @@ package sqlc import ( "database/sql/driver" + "encoding/json" "fmt" "time" @@ -223,11 +224,11 @@ type ReviewImage struct { } type Signal struct { - ID int64 `json:"id"` - TitleID *int64 `json:"title_id"` - RawData []byte `json:"raw_data"` - ProviderID int64 `json:"provider_id"` - Pending bool `json:"pending"` + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` + RawData json.RawMessage `json:"raw_data"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` } type Studio struct { @@ -238,13 +239,13 @@ type Studio struct { } type Tag struct { - ID int64 `json:"id"` - TagNames []byte `json:"tag_names"` + ID int64 `json:"id"` + TagNames json.RawMessage `json:"tag_names"` } type Title struct { ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 7caa7f0..ad1b9a8 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,6 +7,7 @@ package sqlc import ( "context" + "encoding/json" "time" "github.com/jackc/pgx/v5/pgtype" @@ -123,7 +124,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -144,7 +145,7 @@ GROUP BY type GetTitleByIDRow struct { ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -158,7 +159,7 @@ type GetTitleByIDRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames interface{} `json:"tag_names"` + TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -227,15 +228,15 @@ JOIN title_tags as t ON(t.tag_id = g.id) WHERE t.title_id = $1::bigint ` -func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, error) { +func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMessage, error) { rows, err := q.db.Query(ctx, getTitleTags, titleID) if err != nil { return nil, err } defer rows.Close() - items := [][]byte{} + items := []json.RawMessage{} for rows.Next() { - var tag_names []byte + var tag_names json.RawMessage if err := rows.Scan(&tag_names); err != nil { return nil, err } @@ -312,7 +313,7 @@ VALUES ( RETURNING id, tag_names ` -func (q *Queries) InsertTag(ctx context.Context, tagNames []byte) (Tag, error) { +func (q *Queries) InsertTag(ctx context.Context, tagNames json.RawMessage) (Tag, error) { row := q.db.QueryRow(ctx, insertTag, tagNames) var i Tag err := row.Scan(&i.ID, &i.TagNames) @@ -347,7 +348,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -474,7 +475,7 @@ type SearchTitlesParams struct { type SearchTitlesRow struct { ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -488,7 +489,7 @@ type SearchTitlesRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames interface{} `json:"tag_names"` + TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -607,7 +608,7 @@ type SearchUserTitlesRow struct { ReviewID *int64 `json:"review_id"` Ctime pgtype.Timestamptz `json:"ctime"` ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 94b9fb4..3338c35 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,6 +14,8 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - db_type: "jsonb" + go_type: "encoding/json.RawMessage" - db_type: "uuid" nullable: false go_type: From ff84b0052617f7ecd5cc47ca68b65f5ea6a7320d Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:49:32 +0300 Subject: [PATCH 66/97] fix --- modules/backend/handlers/titles.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 84fc87e..ecb2f00 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -339,7 +339,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje if request.Params.Sort != nil { switch string(*request.Params.Sort) { case "year": - tmp := fmt.Sprint("%d", *t.ReleaseYear) + tmp := fmt.Sprint(*t.ReleaseYear) new_cursor.Param = &tmp case "rating": tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) From cbbc2c179d2421f98ad9857441ec5fd0d0b6fa12 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 18:29:49 +0300 Subject: [PATCH 67/97] feat: --- modules/backend/handlers/users.go | 100 +++++++++++++++--------------- modules/backend/queries.sql | 10 ++- sql/queries.sql.go | 30 ++++----- 3 files changed, 74 insertions(+), 66 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 0420b91..1ea2c1a 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -103,33 +103,23 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } - // Forward bool `json:"forward"` - // SortBy string `json:"sort_by"` - // CursorYear *int32 `json:"cursor_year"` - // CursorID *int64 `json:"cursor_id"` - // CursorRating *float64 `json:"cursor_rating"` - // Word *string `json:"word"` - // Ongoing string `json:"ongoing"` - // Planned string `json:"planned"` - // Dropped string `json:"dropped"` - // InProgress string `json:"in-progress"` - // Finished string `json:"finished"` - // Rate *int32 `json:"rate"` - // Rating *float64 `json:"rating"` - // ReleaseYear *int32 `json:"release_year"` - // ReleaseSeason *ReleaseSeasonT `json:"release_season"` - // Limit *int32 `json:"limit"` - params := sqlc.SearchTitlesParams{ - Word: word, - Ongoing: status.ongoing, - Finished: status.finished, - Planned: status.planned, - Rating: request.Params.Rating, - ReleaseYear: request.Params.ReleaseYear, - ReleaseSeason: season, - Forward: true, - SortBy: "id", - Limit: request.Params.Limit, + params := sqlc.SearchUserTitlesParams{ + Forward: true, + SortBy: "id", + // CursorYear : + // CursorID *int64 `json:"cursor_id"` + // CursorRating *float64 `json:"cursor_rating"` + // Word *string `json:"word"` + // Ongoing string `json:"ongoing"` + // Planned string `json:"planned"` + // Dropped string `json:"dropped"` + // InProgress string `json:"in-progress"` + // Finished string `json:"finished"` + // Rate *int32 `json:"rate"` + // Rating *float64 `json:"rating"` + // ReleaseYear *int32 `json:"release_year"` + // ReleaseSeason *ReleaseSeasonT `json:"release_season"` + // Limit *int32 `json:"limit"` } if request.Params.SortForward != nil { @@ -141,32 +131,42 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) - return oapi.GetTitles400Response{}, nil + return oapi.GetUsersUserIdTitles400Response{}, nil } } } - - _sqlc_title := sqlc.SearchTitlesRow{ - ID: sqlc_title.ID, - StudioID: sqlc_title.StudioID, - PosterID: sqlc_title.PosterID, - TitleStatus: sqlc_title.TitleStatus, - Rating: sqlc_title.Rating, - RatingCount: sqlc_title.RatingCount, - ReleaseYear: sqlc_title.ReleaseYear, - ReleaseSeason: sqlc_title.ReleaseSeason, - Season: sqlc_title.Season, - EpisodesAired: sqlc_title.EpisodesAired, - EpisodesAll: sqlc_title.EpisodesAll, - EpisodesLen: sqlc_title.EpisodesLen, - TitleStorageType: sqlc_title.TitleStorageType, - TitleImagePath: sqlc_title.TitleImagePath, - TagNames: sqlc_title.TitleNames, - StudioName: sqlc_title.StudioName, - StudioIllustID: sqlc_title.StudioIllustID, - StudioDesc: sqlc_title.StudioDesc, - StudioStorageType: sqlc_title.StudioStorageType, - StudioImagePath: sqlc_title.StudioImagePath, + // param = nil means it will not be used + titles, err := s.db.SearchUserTitles(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles500Response{}, nil + } + if len(titles) == 0 { + return oapi.GetUsersUserIdTitles204Response{}, nil + } + for _, title := range titles { + _sqlc_title := sqlc.SearchTitlesRow{ + ID: sqlc_title.ID, + StudioID: sqlc_title.StudioID, + PosterID: sqlc_title.PosterID, + TitleStatus: sqlc_title.TitleStatus, + Rating: sqlc_title.Rating, + RatingCount: sqlc_title.RatingCount, + ReleaseYear: sqlc_title.ReleaseYear, + ReleaseSeason: sqlc_title.ReleaseSeason, + Season: sqlc_title.Season, + EpisodesAired: sqlc_title.EpisodesAired, + EpisodesAll: sqlc_title.EpisodesAll, + EpisodesLen: sqlc_title.EpisodesLen, + TitleStorageType: sqlc_title.TitleStorageType, + TitleImagePath: sqlc_title.TitleImagePath, + TagNames: sqlc_title.TitleNames, + StudioName: sqlc_title.StudioName, + StudioIllustID: sqlc_title.StudioIllustID, + StudioDesc: sqlc_title.StudioDesc, + StudioStorageType: sqlc_title.StudioStorageType, + StudioImagePath: sqlc_title.StudioImagePath, + } } return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 307eb4f..ee2a84a 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -95,7 +95,7 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) LEFT JOIN images as si ON (s.illust_id = si.id) -WHERE id = sqlc.arg('title_id')::bigint +WHERE t.id = sqlc.arg('title_id')::bigint GROUP BY t.id, i.id, s.id, si.id; @@ -187,7 +187,13 @@ WHERE END ) - AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality(sqlc.arg('title_statuses')::text[]) = 0 + OR + -- Иначе: статус есть в списке + t.title_status = ANY(sqlc.arg('title_statuses')::text[]) +) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index d4fb7a6..bf2f08a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -138,7 +138,7 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) LEFT JOIN images as si ON (s.illust_id = si.id) -WHERE id = $1::bigint +WHERE t.id = $1::bigint GROUP BY t.id, i.id, s.id, si.id ` @@ -428,10 +428,16 @@ WHERE END ) - AND (t.title_status::text IN ($7::text, $8::text, $9::text)) - AND ($10::float IS NULL OR t.rating >= $10::float) - AND ($11::int IS NULL OR t.release_year = $11::int) - AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality($7::text[]) = 0 + OR + -- Иначе: статус есть в списке + t.title_status = ANY($7::text[]) +) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY t.id, i.id, s.id, si.id @@ -454,7 +460,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($13::int, 100) +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { @@ -464,9 +470,7 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - Ongoing string `json:"ongoing"` - Finished string `json:"finished"` - Planned string `json:"planned"` + TitleStatuses []string `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -505,9 +509,7 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, - arg.Ongoing, - arg.Finished, - arg.Planned, + arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -564,7 +566,7 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -718,7 +720,7 @@ type SearchUserTitlesRow struct { UserCtime pgtype.Timestamptz `json:"user_ctime"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` + TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` From 2929a6e4bc30613efdfe8eb2a6d469a81f5cf208 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 15 Nov 2025 02:53:25 +0300 Subject: [PATCH 68/97] feat: initial auth service support --- Dockerfiles/Dockerfile_auth | 6 + auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/oapi-auth-codegen.yaml | 6 + auth/openapi-auth.yaml | 112 ++++++++++ go.mod | 1 + go.sum | 2 + modules/auth/handlers/handlers.go | 108 ++++++++++ modules/auth/main.go | 38 ++++ modules/auth/types.go | 6 + 10 files changed, 937 insertions(+) create mode 100644 Dockerfiles/Dockerfile_auth create mode 100644 auth/auth.gen.go create mode 100644 auth/auth/auth.gen.go create mode 100644 auth/oapi-auth-codegen.yaml create mode 100644 auth/openapi-auth.yaml create mode 100644 modules/auth/handlers/handlers.go create mode 100644 modules/auth/main.go create mode 100644 modules/auth/types.go diff --git a/Dockerfiles/Dockerfile_auth b/Dockerfiles/Dockerfile_auth new file mode 100644 index 0000000..5280e86 --- /dev/null +++ b/Dockerfiles/Dockerfile_auth @@ -0,0 +1,6 @@ +FROM ubuntu:22.04 + +WORKDIR /app +COPY --chmod=755 modules/auth/auth /app +EXPOSE 8082 +ENTRYPOINT ["/app/auth"] \ No newline at end of file diff --git a/auth/auth.gen.go b/auth/auth.gen.go new file mode 100644 index 0000000..1f16575 --- /dev/null +++ b/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/auth/auth.gen.go b/auth/auth/auth.gen.go new file mode 100644 index 0000000..12b6622 --- /dev/null +++ b/auth/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package oapi_auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package oapi_auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/oapi-auth-codegen.yaml b/auth/oapi-auth-codegen.yaml new file mode 100644 index 0000000..6792391 --- /dev/null +++ b/auth/oapi-auth-codegen.yaml @@ -0,0 +1,6 @@ +package: auth +generate: + strict-server: true + gin-server: true + models: true +output: auth/auth.gen.go \ No newline at end of file diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml new file mode 100644 index 0000000..7ffc60e --- /dev/null +++ b/auth/openapi-auth.yaml @@ -0,0 +1,112 @@ +openapi: 3.1.0 +info: + title: Auth Service + version: 1.0.0 + +paths: + /auth/sign-up: + post: + summary: Sign up a new user + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-up result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + + /auth/sign-in: + post: + summary: Sign in a user and return JWT + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-in result with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + token: + type: string + description: JWT token to access protected endpoints + nullable: true + + /auth/verify-token: + post: + summary: Verify JWT validity + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + description: JWT token to validate + responses: + "200": + description: Token validation result + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + description: True if token is valid + user_id: + type: string + nullable: true + description: User ID extracted from token if valid + error: + type: string + nullable: true + description: Error message if token is invalid \ No newline at end of file diff --git a/go.mod b/go.mod index 80a9ab1..bf73121 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 diff --git a/go.sum b/go.sum index 121ca40..8f46514 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go new file mode 100644 index 0000000..ca72192 --- /dev/null +++ b/modules/auth/handlers/handlers.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "context" + "fmt" + auth "nyanimedb/auth" + sqlc "nyanimedb/sql" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var secretKey = []byte("my_secret_key") + +func generateToken(userID string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(time.Hour * 24).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secretKey) +} + +var UserDb = make(map[string]string) //TEMP + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} + +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + err := "" + success := true + t, _ := generateToken(req.Body.Nickname) + + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + Token: &t, + }, nil +} + +func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { + err := "" + success := true + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignUp200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + }, nil +} + +func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { + valid := false + var userID *string + var errStr *string + + token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return secretKey, nil + }) + + if err != nil { + e := err.Error() + errStr = &e + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if uid, ok := claims["user_id"].(string); ok { + valid = true + userID = &uid + } else { + e := "user_id not found in token" + errStr = &e + } + } else { + e := "invalid token claims" + errStr = &e + } + + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go new file mode 100644 index 0000000..c001e8b --- /dev/null +++ b/modules/auth/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "time" + + auth "nyanimedb/auth" + handlers "nyanimedb/modules/auth/handlers" + sqlc "nyanimedb/sql" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +var AppConfig Config + +func main() { + r := gin.Default() + + var queries *sqlc.Queries = nil + + server := handlers.NewServer(queries) + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + auth.RegisterHandlers(r, auth.NewStrictHandler( + server, + []auth.StrictMiddlewareFunc{}, + )) + + r.Run(":8082") +} diff --git a/modules/auth/types.go b/modules/auth/types.go new file mode 100644 index 0000000..038b179 --- /dev/null +++ b/modules/auth/types.go @@ -0,0 +1,6 @@ +package main + +type Config struct { + JwtPrivateKey string + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` +} From 69e8a8dc79b1d696c548400e5abbb225105356b0 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 03:32:58 +0300 Subject: [PATCH 69/97] feat: use SetCookie for access and refresh tokens --- auth/auth.gen.go | 104 ++------------- auth/openapi-auth.yaml | 121 +++++++++++++----- modules/auth/handlers/handlers.go | 203 +++++++++++++++++++++--------- 3 files changed, 246 insertions(+), 182 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 1f16575..adb2b06 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -25,21 +25,12 @@ type PostAuthSignUpJSONBody struct { Pass string `json:"pass"` } -// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. -type PostAuthVerifyTokenJSONBody struct { - // Token JWT token to validate - Token string `json:"token"` -} - // PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody // PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody -// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. -type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody - // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT @@ -48,9 +39,6 @@ type ServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(c *gin.Context) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -88,19 +76,6 @@ func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { siw.Handler.PostAuthSignUp(c) } -// PostAuthVerifyToken operation middleware -func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostAuthVerifyToken(c) -} - // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -130,7 +105,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) - router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) } type PostAuthSignInRequestObject struct { @@ -144,10 +118,7 @@ type PostAuthSignInResponseObject interface { type PostAuthSignIn200JSONResponse struct { Error *string `json:"error"` Success *bool `json:"success,omitempty"` - - // Token JWT token to access protected endpoints - Token *string `json:"token"` - UserId *string `json:"user_id"` + UserId *string `json:"user_id"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -157,6 +128,17 @@ func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http return json.NewEncoder(w).Encode(response) } +type PostAuthSignIn401JSONResponse struct { + Error *string `json:"error,omitempty"` +} + +func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + type PostAuthSignUpRequestObject struct { Body *PostAuthSignUpJSONRequestBody } @@ -178,32 +160,6 @@ func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http return json.NewEncoder(w).Encode(response) } -type PostAuthVerifyTokenRequestObject struct { - Body *PostAuthVerifyTokenJSONRequestBody -} - -type PostAuthVerifyTokenResponseObject interface { - VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error -} - -type PostAuthVerifyToken200JSONResponse struct { - // Error Error message if token is invalid - Error *string `json:"error"` - - // UserId User ID extracted from token if valid - UserId *string `json:"user_id"` - - // Valid True if token is valid - Valid *bool `json:"valid,omitempty"` -} - -func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT @@ -212,9 +168,6 @@ type StrictServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -294,36 +247,3 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } - -// PostAuthVerifyToken operation middleware -func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { - var request PostAuthVerifyTokenRequestObject - - var body PostAuthVerifyTokenJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthVerifyToken") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { - if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 7ffc60e..b9ce76f 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.1.1 info: title: Auth Service version: 1.0.0 @@ -58,6 +58,14 @@ paths: responses: "200": description: Sign-in result with JWT + # headers: + # Set-Cookie: + # schema: + # type: array + # items: + # type: string + # explode: true + # style: simple content: application/json: schema: @@ -71,42 +79,89 @@ paths: user_id: type: string nullable: true - token: - type: string - description: JWT token to access protected endpoints - nullable: true - - /auth/verify-token: - post: - summary: Verify JWT validity - tags: [Auth] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token] - properties: - token: - type: string - description: JWT token to validate - responses: - "200": - description: Token validation result + "401": + description: Access denied due to invalid credentials content: application/json: schema: type: object properties: - valid: - type: boolean - description: True if token is valid - user_id: - type: string - nullable: true - description: User ID extracted from token if valid error: type: string - nullable: true - description: Error message if token is invalid \ No newline at end of file + example: "Access denied" + # /auth/verify-token: + # post: + # summary: Verify JWT validity + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [token] + # properties: + # token: + # type: string + # description: JWT token to validate + # responses: + # "200": + # description: Token validation result + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if token is valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from token if valid + # error: + # type: string + # nullable: true + # description: Error message if token is invalid + # /auth/refresh-token: + # post: + # summary: Refresh JWT using a refresh token + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [refresh_token] + # properties: + # refresh_token: + # type: string + # description: JWT refresh token obtained from sign-in + # responses: + # "200": + # description: New access (and optionally refresh) token + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if refresh token was valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from refresh token + # access_token: + # type: string + # description: New access token + # nullable: true + # refresh_token: + # type: string + # description: New refresh token (optional) + # nullable: true + # error: + # type: string + # nullable: true + # description: Error message if refresh token is invalid diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index ca72192..9b9b0d3 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,27 +3,21 @@ package handlers import ( "context" "fmt" + "log" + "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) -var secretKey = []byte("my_secret_key") +var accessSecret = []byte("my_access_secret_key") +var refreshSecret = []byte("my_refresh_secret_key") -func generateToken(userID string) (string, error) { - claims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(time.Hour * 24).Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(secretKey) -} - -var UserDb = make(map[string]string) //TEMP +var UserDb = make(map[string]string) // TEMP: stores passwords type Server struct { db *sqlc.Queries @@ -38,19 +32,28 @@ func parseInt64(s string) (int32, error) { return int32(i), err } -func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - err := "" - success := true - t, _ := generateToken(req.Body.Nickname) +func generateTokens(userID string) (accessToken string, refreshToken string, err error) { + accessClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + } + at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessToken, err = at.SignedString(accessSecret) + if err != nil { + return "", "", err + } - UserDb[req.Body.Nickname] = req.Body.Pass + refreshClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), + } + rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshToken, err = rt.SignedString(refreshSecret) + if err != nil { + return "", "", err + } - return auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, - Token: &t, - }, nil + return accessToken, refreshToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -65,44 +68,130 @@ func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpReque }, nil } -func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { - valid := false - var userID *string - var errStr *string +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + // ctx.SetCookie("122") + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + // TODO: change to 500 + return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + } - token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - return secretKey, nil - }) + err := "" + success := true - if err != nil { - e := err.Error() - errStr = &e - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, + pass, ok := UserDb[req.Body.Nickname] + if !ok || pass != req.Body.Pass { + e := "invalid credentials" + return auth.PostAuthSignIn401JSONResponse{ + Error: &e, }, nil } - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - if uid, ok := claims["user_id"].(string); ok { - valid = true - userID = &uid - } else { - e := "user_id not found in token" - errStr = &e - } - } else { - e := "invalid token claims" - errStr = &e - } + accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, - }, nil + ginCtx.SetSameSite(http.SameSiteStrictMode) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + + // Return access token; refresh token can be returned in response or HttpOnly cookie + result := auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + } + return result, nil } + +// func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return accessSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// valid = true +// userID = &uid +// } else { +// e := "user_id not found in token" +// errStr = &e +// } +// } else { +// e := "invalid token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return refreshSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// // Refresh token is valid, generate new tokens +// newAccessToken, newRefreshToken, _ := generateTokens(uid) +// valid = true +// userID = &uid +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: nil, +// Token: &newAccessToken, // return new access token +// // optionally return newRefreshToken as well +// }, nil +// } else { +// e := "user_id not found in refresh token" +// errStr = &e +// } +// } else { +// e := "invalid refresh token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } From c500116916cc9c3a6299d7c887386575066db829 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 03:57:35 +0300 Subject: [PATCH 70/97] feat: added login page --- auth/openapi-auth.yaml | 3 + modules/frontend/src/App.tsx | 3 + modules/frontend/src/api/core/OpenAPI.ts | 2 +- modules/frontend/src/auth/core/ApiError.ts | 25 ++ .../src/auth/core/ApiRequestOptions.ts | 17 + modules/frontend/src/auth/core/ApiResult.ts | 11 + .../src/auth/core/CancelablePromise.ts | 131 +++++++ modules/frontend/src/auth/core/OpenAPI.ts | 32 ++ modules/frontend/src/auth/core/request.ts | 323 ++++++++++++++++++ modules/frontend/src/auth/index.ts | 10 + .../frontend/src/auth/services/AuthService.ts | 58 ++++ .../src/pages/LoginPage/LoginPage.tsx | 116 +++++++ 12 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 modules/frontend/src/auth/core/ApiError.ts create mode 100644 modules/frontend/src/auth/core/ApiRequestOptions.ts create mode 100644 modules/frontend/src/auth/core/ApiResult.ts create mode 100644 modules/frontend/src/auth/core/CancelablePromise.ts create mode 100644 modules/frontend/src/auth/core/OpenAPI.ts create mode 100644 modules/frontend/src/auth/core/request.ts create mode 100644 modules/frontend/src/auth/index.ts create mode 100644 modules/frontend/src/auth/services/AuthService.ts create mode 100644 modules/frontend/src/pages/LoginPage/LoginPage.tsx diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index b9ce76f..913c000 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -3,6 +3,9 @@ info: title: Auth Service version: 1.0.0 +servers: + - url: /auth + paths: /auth/sign-up: post: diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 909ad6c..5a25313 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { @@ -10,6 +11,8 @@ const App: React.FC = () => { <Router> <Header username={username} /> <Routes> + <Route path="/login" element={<LoginPage />} /> {/* <-- маршрут для логина */} + <Route path="/signup" element={<LoginPage />} /> {/* <-- можно использовать тот же компонент для регистрации */} <Route path="/users/:id" element={<UserPage />} /> <Route path="/titles" element={<TitlesPage />} /> </Routes> diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 185e5c3..6ce873e 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', + BASE: 'http://10.1.0.65:8081/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/core/ApiError.ts b/modules/frontend/src/auth/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/auth/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/auth/core/ApiRequestOptions.ts b/modules/frontend/src/auth/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/auth/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record<string, any>; + readonly cookies?: Record<string, any>; + readonly headers?: Record<string, any>; + readonly query?: Record<string, any>; + readonly formData?: Record<string, any>; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record<number, string>; +}; diff --git a/modules/frontend/src/auth/core/ApiResult.ts b/modules/frontend/src/auth/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/auth/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/auth/core/CancelablePromise.ts b/modules/frontend/src/auth/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/auth/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise<T> implements Promise<T> { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise<T>; + #resolve?: (value: T | PromiseLike<T>) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike<T>) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise<T>((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike<T>): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then<TResult1 = T, TResult2 = never>( + onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null + ): Promise<TResult1 | TResult2> { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch<TResult = never>( + onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null + ): Promise<T | TResult> { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise<T> { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts new file mode 100644 index 0000000..27bd73f --- /dev/null +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; +type Headers = Record<string, string>; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver<string> | undefined; + USERNAME?: string | Resolver<string> | undefined; + PASSWORD?: string | Resolver<string> | undefined; + HEADERS?: Headers | Resolver<Headers> | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'http://127.0.0.1:8082', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/auth/core/request.ts b/modules/frontend/src/auth/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/auth/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record<string, any>): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; + +export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { + if (typeof resolver === 'function') { + return (resolver as Resolver<T>)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record<string, string>); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async <T>( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record<string, string>, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise<AxiosResponse<T>> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError<T>; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse<any>): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record<number, string> = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise<T> + * @throws ApiError + */ +export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/auth/index.ts b/modules/frontend/src/auth/index.ts new file mode 100644 index 0000000..b0989c4 --- /dev/null +++ b/modules/frontend/src/auth/index.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export { AuthService } from './services/AuthService'; diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts new file mode 100644 index 0000000..bab9c77 --- /dev/null +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -0,0 +1,58 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class AuthService { + /** + * Sign up a new user + * @param requestBody + * @returns any Sign-up result + * @throws ApiError + */ + public static postAuthSignUp( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string | null; + user_id?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-up', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Sign in a user and return JWT + * @param requestBody + * @returns any Sign-in result with JWT + * @throws ApiError + */ + public static postAuthSignIn( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string | null; + user_id?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-in', + body: requestBody, + mediaType: 'application/json', + errors: { + 401: `Access denied due to invalid credentials`, + }, + }); + } +} diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..dcd6965 --- /dev/null +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,116 @@ +import React, { useState } from "react"; +import { AuthService } from "../../auth/services/AuthService"; +import { useNavigate } from "react-router-dom"; + +export const LoginPage: React.FC = () => { + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(true); // true = login, false = signup + const [nickname, setNickname] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + if (isLogin) { + const res = await AuthService.postAuthSignIn({ nickname, pass: password }); + if (res.success) { + // TODO: сохранить JWT в localStorage/cookie + console.log("Logged in user id:", res.user_id); + navigate("/"); // редирект после успешного входа + } else { + setError(res.error || "Login failed"); + } + } else { + const res = await AuthService.postAuthSignUp({ nickname, pass: password }); + if (res.success) { + console.log("User signed up with id:", res.user_id); + setIsLogin(true); // переключаемся на login после регистрации + } else { + setError(res.error || "Sign up failed"); + } + } + } catch (err: any) { + console.error(err); + setError(err?.message || "Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> + <div className="max-w-md w-full bg-white shadow-md rounded-lg p-8"> + <h2 className="text-2xl font-bold mb-6 text-center"> + {isLogin ? "Login" : "Sign Up"} + </h2> + + {error && <div className="text-red-600 mb-4">{error}</div>} + + <form onSubmit={handleSubmit} className="space-y-4"> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1"> + Nickname + </label> + <input + type="text" + value={nickname} + onChange={(e) => setNickname(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> + </div> + + <div> + <label className="block text-sm font-medium text-gray-700 mb-1"> + Password + </label> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> + </div> + + <button + type="submit" + disabled={loading} + className="w-full bg-blue-600 text-white py-2 rounded-lg font-semibold hover:bg-blue-700 transition disabled:opacity-50" + > + {loading ? "Please wait..." : isLogin ? "Login" : "Sign Up"} + </button> + </form> + + <div className="mt-4 text-center text-sm text-gray-600"> + {isLogin ? ( + <> + Don't have an account?{" "} + <button + onClick={() => setIsLogin(false)} + className="text-blue-600 hover:underline" + > + Sign Up + </button> + </> + ) : ( + <> + Already have an account?{" "} + <button + onClick={() => setIsLogin(true)} + className="text-blue-600 hover:underline" + > + Login + </button> + </> + )} + </div> + </div> + </div> + ); +}; From 0942df1fa404a572d20e0110867a2c2d11493fd9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 04:01:29 +0300 Subject: [PATCH 71/97] feat: auth container --- deploy/docker-compose.yml | 12 ++++++++++++ modules/frontend/nginx-default.conf | 9 +++++++++ modules/frontend/src/auth/core/OpenAPI.ts | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1a96253..7f53da5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -37,6 +37,18 @@ services: - "8080:8080" depends_on: - postgres + + nyanimedb-auth: + image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + container_name: nyanimedb-auth + restart: always + environment: + LOG_LEVEL: ${LOG_LEVEL} + DATABASE_URL: ${DATABASE_URL} + ports: + - "8082:8082" + depends_on: + - postgres nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index a538968..c3a851f 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -19,6 +19,15 @@ server { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } + location /auth/ { + rewrite ^/auth/(.*)$ /$1 break; + proxy_pass http://nyanimedb-auth:8082/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } #error_page 404 /404.html; error_page 500 502 503 504 /50x.html; diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 27bd73f..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://127.0.0.1:8082', + BASE: '/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', From 0c94930bca1cbf63623291b43acde17a5a10579e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 04:02:23 +0300 Subject: [PATCH 72/97] fix: useUnionTypes for TS oapi codegen --- deploy/generate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/generate.sh b/deploy/generate.sh index d7d94a2..29587cf 100644 --- a/deploy/generate.sh +++ b/deploy/generate.sh @@ -1,3 +1,3 @@ -npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios +npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios --useUnionTypes oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml sqlc generate -f .\sql\sqlc.yaml \ No newline at end of file From e792d5780b67d210a60e71a2566848c247384042 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 05:14:23 +0300 Subject: [PATCH 73/97] feat: GetUsertitles implemented --- api/_build/openapi.yaml | 25 +++- api/api.gen.go | 36 ++++- api/paths/users-id-titles.yaml | 25 +++- modules/backend/handlers/common.go | 64 +++++---- modules/backend/handlers/titles.go | 110 ++++++++-------- modules/backend/handlers/users.go | 205 ++++++++++++++++++++--------- modules/backend/queries.sql | 72 ++++++---- sql/queries.sql.go | 202 ++++++++++++++-------------- 8 files changed, 456 insertions(+), 283 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 722b7af..b3eacb4 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -146,11 +146,17 @@ paths: summary: Get user titles parameters: - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' - in: path name: user_id required: true schema: type: string + - in: query + name: sort_forward + schema: + type: boolean + default: true - in: query name: word schema: @@ -173,6 +179,11 @@ paths: schema: type: number format: double + - in: query + name: my_rate + schema: + type: integer + format: int32 - in: query name: release_year schema: @@ -199,9 +210,17 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/UserTitle' + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserTitle' + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor '204': description: No titles found '400': diff --git a/api/api.gen.go b/api/api.gen.go index 54c8fc1..e1f94c2 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -183,13 +183,16 @@ type GetUsersUserIdParams struct { // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` // Status List of title statuses to filter Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` @@ -803,6 +806,22 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) @@ -835,6 +854,14 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } + // ------------- Optional query parameter "my_rate" ------------- + + err = runtime.BindQueryParameter("form", true, false, "my_rate", c.Request.URL.Query(), ¶ms.MyRate) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter my_rate: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "release_year" ------------- err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) @@ -1057,7 +1084,10 @@ type GetUsersUserIdTitlesResponseObject interface { VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error } -type GetUsersUserIdTitles200JSONResponse []UserTitle +type GetUsersUserIdTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + Data []UserTitle `json:"data"` +} func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0788319..91b212d 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -2,11 +2,17 @@ get: summary: Get user titles parameters: - $ref: '../parameters/cursor.yaml' + - $ref: "../parameters/title_sort.yaml" - in: path name: user_id required: true schema: type: string + - in: query + name: sort_forward + schema: + type: boolean + default: true - in: query name: word schema: @@ -29,6 +35,11 @@ get: schema: type: number format: double + - in: query + name: my_rate + schema: + type: integer + format: int32 - in: query name: release_year schema: @@ -55,9 +66,17 @@ get: content: application/json: schema: - type: array - items: - $ref: '../schemas/UserTitle.yaml' + type: object + properties: + data: + type: array + items: + $ref: '../schemas/UserTitle.yaml' + cursor: + $ref: '../schemas/CursorObj.yaml' + required: + - data + - cursor '204': description: No titles found '400': diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 6618d49..89a3d59 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -17,7 +17,24 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } -func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { +func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { + + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAired, + // EpisodesLen: &episodes_lens, + Id: title.ID, + // Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + // ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + // Studio: &oapi_studio, + // Tags: oapi_tag_names, + // TitleNames: title_names, + // TitleStatus: oapi_status, + // AdditionalProperties: + } title_names := make(map[string][]string, 0) err := json.Unmarshal(title.TitleNames, &title_names) @@ -25,10 +42,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi. return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } - episodes_lens := make(map[string]float64, 0) - err = json.Unmarshal(title.EpisodesLen, &episodes_lens) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + if title.EpisodesLen != nil && len(title.EpisodesLen) > 0 { + episodes_lens := make(map[string]float64, 0) + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } + oapi_title.EpisodesLen = &episodes_lens } oapi_tag_names := make(oapi.Tags, 0) @@ -38,17 +58,19 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi. } var oapi_studio oapi.Studio - - oapi_studio.Id = title.StudioID if title.StudioName != nil { oapi_studio.Name = *title.StudioName } - oapi_studio.Description = title.StudioDesc - if title.StudioIllustID != nil { - oapi_studio.Poster.Id = title.StudioIllustID - oapi_studio.Poster.ImagePath = title.StudioImagePath - oapi_studio.Poster.StorageType = &title.StudioStorageType + if title.StudioID != 0 { + oapi_studio.Id = title.StudioID + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + oapi_studio.Poster.StorageType = &title.StudioStorageType + } } + oapi_title.Studio = &oapi_studio var oapi_image oapi.Image @@ -62,27 +84,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi. if title.ReleaseSeason != nil { release_season = oapi.ReleaseSeason(*title.ReleaseSeason) } + oapi_title.ReleaseSeason = &release_season oapi_status, err := TitleStatus2oapi(&title.TitleStatus) if err != nil { return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) } - oapi_title := oapi.Title{ - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAired, - EpisodesLen: &episodes_lens, - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: &release_season, - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: title_names, - TitleStatus: oapi_status, - // AdditionalProperties: - } + oapi_title.TitleStatus = oapi_status return oapi_title, nil } diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 78323f6..d593314 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -26,25 +26,25 @@ type SqlcStatus struct { planned string } -func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { - var sqlc_status SqlcStatus - if s == nil { - return &sqlc_status, nil - } - for _, t := range *s { - switch t { - case oapi.TitleStatusFinished: - sqlc_status.finished = "finished" - case oapi.TitleStatusOngoing: - sqlc_status.ongoing = "ongoing" - case oapi.TitleStatusPlanned: - sqlc_status.planned = "planned" - default: - return nil, fmt.Errorf("unexpected tittle status: %s", t) - } - } - return &sqlc_status, nil -} +// func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { +// var sqlc_status SqlcStatus +// if s == nil { +// return &sqlc_status, nil +// } +// for _, t := range *s { +// switch t { +// case oapi.TitleStatusFinished: +// sqlc_status.finished = "finished" +// case oapi.TitleStatusOngoing: +// sqlc_status.ongoing = "ongoing" +// case oapi.TitleStatusPlanned: +// sqlc_status.planned = "planned" +// default: +// return nil, fmt.Errorf("unexpected tittle status: %s", t) +// } +// } +// return &sqlc_status, nil +// } func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { if s == nil { @@ -169,29 +169,8 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil } - _sqlc_title := sqlc.SearchTitlesRow{ - ID: sqlc_title.ID, - StudioID: sqlc_title.StudioID, - PosterID: sqlc_title.PosterID, - TitleStatus: sqlc_title.TitleStatus, - Rating: sqlc_title.Rating, - RatingCount: sqlc_title.RatingCount, - ReleaseYear: sqlc_title.ReleaseYear, - ReleaseSeason: sqlc_title.ReleaseSeason, - Season: sqlc_title.Season, - EpisodesAired: sqlc_title.EpisodesAired, - EpisodesAll: sqlc_title.EpisodesAll, - EpisodesLen: sqlc_title.EpisodesLen, - TitleStorageType: sqlc_title.TitleStorageType, - TitleImagePath: sqlc_title.TitleImagePath, - TagNames: sqlc_title.TitleNames, - StudioName: sqlc_title.StudioName, - StudioIllustID: sqlc_title.StudioIllustID, - StudioDesc: sqlc_title.StudioDesc, - StudioStorageType: sqlc_title.StudioStorageType, - StudioImagePath: sqlc_title.StudioImagePath, - } - oapi_title, err = s.mapTitle(ctx, _sqlc_title) + + oapi_title, err = s.mapTitle(ctx, sqlc_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil @@ -204,11 +183,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(request.Params.Status) - if err != nil { - log.Errorf("%v", err) - return oapi.GetTitles400Response{}, err - } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { @@ -216,16 +190,22 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles400Response{}, err } + var statuses_sort []string + if request.Params.Status != nil { + for _, s := range *request.Params.Status { + ss := string(s) // s type is alias for string + statuses_sort = append(statuses_sort, ss) + } + } + params := sqlc.SearchTitlesParams{ Word: word, - Ongoing: status.ongoing, - Finished: status.finished, - Planned: status.planned, + TitleStatuses: statuses_sort, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, - Forward: true, - SortBy: "id", + Forward: true, // default + SortBy: "id", // default Limit: request.Params.Limit, } @@ -235,6 +215,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje if request.Params.Sort != nil { params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) @@ -256,7 +237,30 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje for _, title := range titles { - t, err := s.mapTitle(ctx, title) + _title := sqlc.GetTitleByIDRow{ + ID: title.ID, + // StudioID: title.StudioID, + PosterID: title.PosterID, + TitleStatus: title.TitleStatus, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseYear: title.ReleaseYear, + ReleaseSeason: title.ReleaseSeason, + Season: title.Season, + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: title.TitleStorageType, + TitleImagePath: title.TitleImagePath, + TagNames: title.TitleNames, + StudioName: title.StudioName, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + t, err := s.mapTitle(ctx, _title) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 1ea2c1a..3223389 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -5,6 +5,7 @@ import ( "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "strconv" "time" "github.com/jackc/pgx/v5" @@ -53,8 +54,12 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil } -func sqlDate2oapi(p_date pgtype.Timestamptz) (time.Time, error) { - return time.Time{}, nil +func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { + if p_date.Valid { + t := p_date.Time + return &t + } + return nil } type SqlcUserStatus struct { @@ -64,26 +69,94 @@ type SqlcUserStatus struct { in_progress string } -func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { - var sqlc_status SqlcUserStatus - if s == nil { - return &sqlc_status, nil +// func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { +// var sqlc_status SqlcUserStatus +// if s == nil { +// return &sqlc_status, nil +// } +// for _, t := range *s { +// switch t { +// case oapi.UserTitleStatusFinished: +// sqlc_status.finished = "finished" +// case oapi.UserTitleStatusDropped: +// sqlc_status.dropped = "dropped" +// case oapi.UserTitleStatusPlanned: +// sqlc_status.planned = "planned" +// case oapi.UserTitleStatusInProgress: +// sqlc_status.in_progress = "in-progress" +// default: +// return nil, fmt.Errorf("unexpected tittle status: %s", t) +// } +// } +// return &sqlc_status, nil +// } + +func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) { + var status oapi.UserTitleStatus + + switch status { + case "finished": + status = oapi.UserTitleStatusFinished + case "dropped": + status = oapi.UserTitleStatusDropped + case "planned": + status = oapi.UserTitleStatusPlanned + case "in-progress": + status = oapi.UserTitleStatusInProgress + default: + return status, fmt.Errorf("unexpected tittle status: %s", s) } - for _, t := range *s { - switch t { - case oapi.UserTitleStatusFinished: - sqlc_status.finished = "finished" - case oapi.UserTitleStatusDropped: - sqlc_status.dropped = "dropped" - case oapi.UserTitleStatusPlanned: - sqlc_status.planned = "planned" - case oapi.UserTitleStatusInProgress: - sqlc_status.in_progress = "in-progress" - default: - return nil, fmt.Errorf("unexpected tittle status: %s", t) - } + + return status, nil +} + +func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { + + oapi_usertitle := oapi.UserTitle{ + Ctime: sqlDate2oapi(t.UserCtime), + Rate: t.UserRate, + ReviewId: t.ReviewID, + // Status: , + // Title: , + UserId: t.UserID, } - return &sqlc_status, nil + + status, err := sql2usertitlestatus(t.UsertitleStatus) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Status = status + + _title := sqlc.GetTitleByIDRow{ + ID: t.ID, + // StudioID: title.StudioID, + PosterID: t.PosterID, + TitleStatus: t.TitleStatus, + Rating: t.Rating, + RatingCount: t.RatingCount, + ReleaseYear: t.ReleaseYear, + ReleaseSeason: t.ReleaseSeason, + Season: t.Season, + EpisodesAired: t.EpisodesAired, + EpisodesAll: t.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: t.TitleStorageType, + TitleImagePath: t.TitleImagePath, + TagNames: t.TitleNames, + StudioName: t.StudioName, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + oapi_title, err := s.mapTitle(ctx, _title) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Title = &oapi_title + + return oapi_usertitle, nil } func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { @@ -91,11 +164,6 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU oapi_usertitles := make([]oapi.UserTitle, 0) word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(request.Params.Status) - if err != nil { - log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err - } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { @@ -103,23 +171,33 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } + var statuses_sort []string + if request.Params.Status != nil { + for _, s := range *request.Params.Status { + ss := string(s) // s type is alias for string + statuses_sort = append(statuses_sort, ss) + } + } + + var watch_status []string + if request.Params.WatchStatus != nil { + for _, s := range *request.Params.WatchStatus { + ss := string(s) // s type is alias for string + watch_status = append(statuses_sort, ss) + } + } + params := sqlc.SearchUserTitlesParams{ - Forward: true, - SortBy: "id", - // CursorYear : - // CursorID *int64 `json:"cursor_id"` - // CursorRating *float64 `json:"cursor_rating"` - // Word *string `json:"word"` - // Ongoing string `json:"ongoing"` - // Planned string `json:"planned"` - // Dropped string `json:"dropped"` - // InProgress string `json:"in-progress"` - // Finished string `json:"finished"` - // Rate *int32 `json:"rate"` - // Rating *float64 `json:"rating"` - // ReleaseYear *int32 `json:"release_year"` - // ReleaseSeason *ReleaseSeasonT `json:"release_season"` - // Limit *int32 `json:"limit"` + Word: word, + TitleStatuses: statuses_sort, + UsertitleStatuses: watch_status, + Rating: request.Params.Rating, + Rate: request.Params.MyRate, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Forward: true, // default + SortBy: "id", // default + Limit: request.Params.Limit, } if request.Params.SortForward != nil { @@ -128,6 +206,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU if request.Params.Sort != nil { params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) @@ -144,30 +223,30 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU if len(titles) == 0 { return oapi.GetUsersUserIdTitles204Response{}, nil } + + var new_cursor oapi.CursorObj + for _, title := range titles { - _sqlc_title := sqlc.SearchTitlesRow{ - ID: sqlc_title.ID, - StudioID: sqlc_title.StudioID, - PosterID: sqlc_title.PosterID, - TitleStatus: sqlc_title.TitleStatus, - Rating: sqlc_title.Rating, - RatingCount: sqlc_title.RatingCount, - ReleaseYear: sqlc_title.ReleaseYear, - ReleaseSeason: sqlc_title.ReleaseSeason, - Season: sqlc_title.Season, - EpisodesAired: sqlc_title.EpisodesAired, - EpisodesAll: sqlc_title.EpisodesAll, - EpisodesLen: sqlc_title.EpisodesLen, - TitleStorageType: sqlc_title.TitleStorageType, - TitleImagePath: sqlc_title.TitleImagePath, - TagNames: sqlc_title.TitleNames, - StudioName: sqlc_title.StudioName, - StudioIllustID: sqlc_title.StudioIllustID, - StudioDesc: sqlc_title.StudioDesc, - StudioStorageType: sqlc_title.StudioStorageType, - StudioImagePath: sqlc_title.StudioImagePath, + + t, err := s.mapUsertitle(ctx, title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles500Response{}, nil + } + oapi_usertitles = append(oapi_usertitles, t) + + new_cursor.Id = t.Title.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := fmt.Sprint(*t.Title.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) + new_cursor.Param = &tmp + } } } - return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil + return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ee2a84a..9734ff2 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -101,25 +101,30 @@ GROUP BY -- name: SearchTitles :many SELECT - t.*, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, i.storage_type::text as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + s.studio_name as studio_name FROM titles as t LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) WHERE CASE @@ -199,7 +204,7 @@ WHERE AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN @@ -223,7 +228,17 @@ LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- name: SearchUserTitles :many SELECT - t.*, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, u.user_id as user_id, u.status as usertitle_status, u.rate as user_rate, @@ -231,20 +246,18 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name -FROM usertitles as u -LEFT JOIN titles as t ON (u.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE CASE @@ -312,15 +325,24 @@ WHERE END ) - AND (u.status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('planned')::text, sqlc.arg('dropped')::text, sqlc.arg('in-progress')::text)) - AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality(sqlc.arg('title_statuses')::text[]) = 0 + OR + t.title_status = ANY(sqlc.arg('title_statuses')::text[]) + ) + AND ( + cardinality(sqlc.arg('usertitle_statuses')::text[]) = 0 + OR + u.status = ANY(sqlc.arg('usertitle_statuses')::text[]) + ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN diff --git a/sql/queries.sql.go b/sql/queries.sql.go index bf2f08a..f7535db 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -342,25 +342,30 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams const searchTitles = `-- name: SearchTitles :many SELECT - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, i.storage_type::text as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + s.studio_name as studio_name FROM titles as t LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) WHERE CASE @@ -440,7 +445,7 @@ WHERE AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN $1::boolean THEN @@ -478,27 +483,21 @@ type SearchTitlesParams struct { } type SearchTitlesRow struct { - ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType string `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType string `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { @@ -525,7 +524,6 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S if err := rows.Scan( &i.ID, &i.TitleNames, - &i.StudioID, &i.PosterID, &i.TitleStatus, &i.Rating, @@ -535,15 +533,10 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S &i.Season, &i.EpisodesAired, &i.EpisodesAll, - &i.EpisodesLen, &i.TitleStorageType, &i.TitleImagePath, &i.TagNames, &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ); err != nil { return nil, err } @@ -558,7 +551,17 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many SELECT - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, u.user_id as user_id, u.status as usertitle_status, u.rate as user_rate, @@ -566,20 +569,18 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name FROM usertitles as u -LEFT JOIN titles as t ON (u.title_id = t.id) +JOIN titles as t ON (u.title_id = t.id) LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) WHERE CASE @@ -647,15 +648,24 @@ WHERE END ) - AND (u.status::text IN ($7::text, $8::text, $9::text, $10::text)) - AND (t.title_status::text IN ($7::text, $11::text, $8::text)) - AND ($12::int IS NULL OR u.rate >= $12::int) - AND ($13::float IS NULL OR t.rating >= $13::float) - AND ($14::int IS NULL OR t.release_year = $14::int) - AND ($15::release_season_t IS NULL OR t.release_season = $15::release_season_t) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality($7::text[]) = 0 + OR + t.title_status = ANY($7::text[]) + ) + AND ( + cardinality($8::text[]) = 0 + OR + u.status = ANY($8::text[]) + ) + AND ($9::int IS NULL OR u.rate >= $9::int) + AND ($10::float IS NULL OR t.rating >= $10::float) + AND ($11::int IS NULL OR t.release_year = $11::int) + AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN $1::boolean THEN @@ -677,55 +687,46 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($16::int, 100) +LIMIT COALESCE($13::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - Ongoing string `json:"ongoing"` - Planned string `json:"planned"` - Dropped string `json:"dropped"` - InProgress string `json:"in-progress"` - Finished string `json:"finished"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []string `json:"title_statuses"` + UsertitleStatuses []string `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { - ID *int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID *int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus *TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - UserID int64 `json:"user_id"` - UsertitleStatus UsertitleStatusT `json:"usertitle_status"` - UserRate *int32 `json:"user_rate"` - ReviewID *int64 `json:"review_id"` - UserCtime pgtype.Timestamptz `json:"user_ctime"` - TitleStorageType string `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType string `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime pgtype.Timestamptz `json:"user_ctime"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } // 100 is default limit @@ -737,11 +738,8 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara arg.CursorID, arg.CursorRating, arg.Word, - arg.Ongoing, - arg.Planned, - arg.Dropped, - arg.InProgress, - arg.Finished, + arg.TitleStatuses, + arg.UsertitleStatuses, arg.Rate, arg.Rating, arg.ReleaseYear, @@ -758,7 +756,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara if err := rows.Scan( &i.ID, &i.TitleNames, - &i.StudioID, &i.PosterID, &i.TitleStatus, &i.Rating, @@ -768,7 +765,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.Season, &i.EpisodesAired, &i.EpisodesAll, - &i.EpisodesLen, &i.UserID, &i.UsertitleStatus, &i.UserRate, @@ -778,10 +774,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.TitleImagePath, &i.TagNames, &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ); err != nil { return nil, err } From d1937fcbd7e62a4415cace621a7c8d7491f68b7c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 05:28:32 +0300 Subject: [PATCH 74/97] fix: bad types in query --- modules/backend/queries.sql | 12 ++++----- sql/queries.sql.go | 52 ++++++++++++++++++------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 9734ff2..1d5cc45 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -194,10 +194,10 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::text[]) = 0 + cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 OR -- Иначе: статус есть в списке - t.title_status = ANY(sqlc.arg('title_statuses')::text[]) + t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) @@ -327,14 +327,14 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::text[]) = 0 + cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 OR - t.title_status = ANY(sqlc.arg('title_statuses')::text[]) + t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) ) AND ( - cardinality(sqlc.arg('usertitle_statuses')::text[]) = 0 + cardinality(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) = 0 OR - u.status = ANY(sqlc.arg('usertitle_statuses')::text[]) + u.status = ANY(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index f7535db..daa2875 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -435,10 +435,10 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::text[]) = 0 + cardinality($7::title_status_t[]) = 0 OR -- Иначе: статус есть в списке - t.title_status = ANY($7::text[]) + t.title_status = ANY($7::title_status_t[]) ) AND ($8::float IS NULL OR t.rating >= $8::float) AND ($9::int IS NULL OR t.release_year = $9::int) @@ -475,7 +475,7 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - TitleStatuses []string `json:"title_statuses"` + TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -575,12 +575,12 @@ SELECT )::jsonb as tag_names, s.studio_name as studio_name -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE CASE @@ -650,14 +650,14 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::text[]) = 0 + cardinality($7::title_status_t[]) = 0 OR - t.title_status = ANY($7::text[]) + t.title_status = ANY($7::title_status_t[]) ) AND ( - cardinality($8::text[]) = 0 + cardinality($8::usertitle_status_t[]) = 0 OR - u.status = ANY($8::text[]) + u.status = ANY($8::usertitle_status_t[]) ) AND ($9::int IS NULL OR u.rate >= $9::int) AND ($10::float IS NULL OR t.rating >= $10::float) @@ -691,19 +691,19 @@ LIMIT COALESCE($13::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - TitleStatuses []string `json:"title_statuses"` - UsertitleStatuses []string `json:"usertitle_statuses"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { From b42fb34903955cc82414737b238da063db5bceb3 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 05:51:45 +0300 Subject: [PATCH 75/97] fix: type cast fixed --- api/_build/openapi.yaml | 6 ++- api/api.gen.go | 18 ++++----- api/paths/users-id-titles.yaml | 6 ++- modules/backend/handlers/common.go | 22 ++++++++++- modules/backend/handlers/titles.go | 38 +++---------------- modules/backend/handlers/users.go | 59 ++++++++++++++++++++---------- 6 files changed, 84 insertions(+), 65 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index b3eacb4..215eabc 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -173,7 +173,11 @@ paths: - in: query name: watch_status schema: - $ref: '#/components/schemas/UserTitleStatus' + type: array + items: + $ref: '#/components/schemas/UserTitleStatus' + style: form + explode: false - in: query name: rating schema: diff --git a/api/api.gen.go b/api/api.gen.go index e1f94c2..dcc2f89 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -189,14 +189,14 @@ type GetUsersUserIdTitlesParams struct { Word *string `form:"word,omitempty" json:"word,omitempty"` // Status List of title statuses to filter - Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` - WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` - Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` - MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` - ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` - ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` - Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` + WatchStatus *[]UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } // Getter for additional properties for Title. Returns the specified @@ -840,7 +840,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { // ------------- Optional query parameter "watch_status" ------------- - err = runtime.BindQueryParameter("form", true, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) + err = runtime.BindQueryParameter("form", false, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter watch_status: %w", err), http.StatusBadRequest) return diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 91b212d..a76cc40 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -29,7 +29,11 @@ get: - in: query name: watch_status schema: - $ref: '../schemas/enums/UserTitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/UserTitleStatus.yaml' + style: form + explode: false - in: query name: rating schema: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 89a3d59..dc3a4ba 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -42,7 +42,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } - if title.EpisodesLen != nil && len(title.EpisodesLen) > 0 { + if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { @@ -99,3 +99,23 @@ func parseInt64(s string) (int32, error) { i, err := strconv.ParseInt(s, 10, 64) return int32(i), err } + +func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) { + var sqlc_status []sqlc.TitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.TitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTFinished) + case oapi.TitleStatusOngoing: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTOngoing) + case oapi.TitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index d593314..054b745 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -20,32 +20,6 @@ func Word2Sqlc(s *string) *string { return s } -type SqlcStatus struct { - ongoing string - finished string - planned string -} - -// func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { -// var sqlc_status SqlcStatus -// if s == nil { -// return &sqlc_status, nil -// } -// for _, t := range *s { -// switch t { -// case oapi.TitleStatusFinished: -// sqlc_status.finished = "finished" -// case oapi.TitleStatusOngoing: -// sqlc_status.ongoing = "ongoing" -// case oapi.TitleStatusPlanned: -// sqlc_status.planned = "planned" -// default: -// return nil, fmt.Errorf("unexpected tittle status: %s", t) -// } -// } -// return &sqlc_status, nil -// } - func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { if s == nil { return nil, nil @@ -190,17 +164,15 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles400Response{}, err } - var statuses_sort []string - if request.Params.Status != nil { - for _, s := range *request.Params.Status { - ss := string(s) // s type is alias for string - statuses_sort = append(statuses_sort, ss) - } + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, err } params := sqlc.SearchTitlesParams{ Word: word, - TitleStatuses: statuses_sort, + TitleStatuses: title_statuses, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3223389..3a271d7 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -62,13 +62,6 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { return nil } -type SqlcUserStatus struct { - dropped string - finished string - planned string - in_progress string -} - // func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { // var sqlc_status SqlcUserStatus // if s == nil { @@ -110,6 +103,28 @@ func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) return status, nil } +func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, error) { + var sqlc_status []sqlc.UsertitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.UserTitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTFinished) + case oapi.UserTitleStatusInProgress: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTInProgress) + case oapi.UserTitleStatusDropped: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTDropped) + case oapi.UserTitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil +} + func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { oapi_usertitle := oapi.UserTitle{ @@ -171,25 +186,29 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } - var statuses_sort []string - if request.Params.Status != nil { - for _, s := range *request.Params.Status { - ss := string(s) // s type is alias for string - statuses_sort = append(statuses_sort, ss) - } + // var statuses_sort []string + // if request.Params.Status != nil { + // for _, s := range *request.Params.Status { + // ss := string(s) // s type is alias for string + // statuses_sort = append(statuses_sort, ss) + // } + // } + + watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err } - var watch_status []string - if request.Params.WatchStatus != nil { - for _, s := range *request.Params.WatchStatus { - ss := string(s) // s type is alias for string - watch_status = append(statuses_sort, ss) - } + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err } params := sqlc.SearchUserTitlesParams{ Word: word, - TitleStatuses: statuses_sort, + TitleStatuses: title_statuses, UsertitleStatuses: watch_status, Rating: request.Params.Rating, Rate: request.Params.MyRate, From 4e732e15423e03d6d48ab643249a225af166dd2b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 06:26:23 +0300 Subject: [PATCH 76/97] fix: bad NULL handling in where clause --- modules/backend/handlers/common.go | 1 + modules/backend/queries.sql | 26 +++++------ sql/queries.sql.go | 72 ++++++++++++++---------------- 3 files changed, 47 insertions(+), 52 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index dc3a4ba..e22ce3f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -65,6 +65,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_studio.Id = title.StudioID oapi_studio.Description = title.StudioDesc if title.StudioIllustID != nil { + oapi_studio.Poster = &oapi.Image{} oapi_studio.Poster.Id = title.StudioIllustID oapi_studio.Poster.ImagePath = title.StudioImagePath oapi_studio.Poster.StorageType = &title.StudioStorageType diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 1d5cc45..d064660 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -193,12 +193,11 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 - OR - -- Иначе: статус есть в списке - t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) -) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) + ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) @@ -326,15 +325,16 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 - OR - t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) ) AND ( - cardinality(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) = 0 - OR - u.status = ANY(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) + 'usertitle_statuses'::title_status_t[] IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index daa2875..daa2b56 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -434,15 +434,14 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::title_status_t[]) = 0 - OR - -- Иначе: статус есть в списке - t.title_status = ANY($7::title_status_t[]) -) - AND ($8::float IS NULL OR t.rating >= $8::float) - AND ($9::int IS NULL OR t.release_year = $9::int) - AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) + ) + AND ($7::float IS NULL OR t.rating >= $7::float) + AND ($8::int IS NULL OR t.release_year = $8::int) + AND ($9::release_season_t IS NULL OR t.release_season = $9::release_season_t) GROUP BY t.id, i.id, s.id @@ -465,7 +464,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($11::int, 100) +LIMIT COALESCE($10::int, 100) ` type SearchTitlesParams struct { @@ -475,7 +474,6 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -508,7 +506,6 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, - arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -649,20 +646,21 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::title_status_t[]) = 0 - OR - t.title_status = ANY($7::title_status_t[]) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) ) AND ( - cardinality($8::usertitle_status_t[]) = 0 - OR - u.status = ANY($8::usertitle_status_t[]) + 'usertitle_statuses'::title_status_t[] IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) ) - AND ($9::int IS NULL OR u.rate >= $9::int) - AND ($10::float IS NULL OR t.rating >= $10::float) - AND ($11::int IS NULL OR t.release_year = $11::int) - AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + AND ($7::int IS NULL OR u.rate >= $7::int) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY t.id, i.id, s.id @@ -687,23 +685,21 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($13::int, 100) +LIMIT COALESCE($11::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - TitleStatuses []TitleStatusT `json:"title_statuses"` - UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { @@ -738,8 +734,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara arg.CursorID, arg.CursorRating, arg.Word, - arg.TitleStatuses, - arg.UsertitleStatuses, arg.Rate, arg.Rating, arg.ReleaseYear, From 20e9c5bf23c42c4cd397caad7c64b47aa084514d Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 06:33:11 +0300 Subject: [PATCH 77/97] fix --- modules/backend/queries.sql | 24 ++++++------- sql/queries.sql.go | 70 ++++++++++++++++++++----------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index d064660..dc81da9 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -193,10 +193,10 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) @@ -325,16 +325,16 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) ) AND ( - 'usertitle_statuses'::title_status_t[] IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) + sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index daa2b56..2df4da8 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -434,14 +434,14 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + $7::title_status_t[] IS NULL + OR array_length($7::title_status_t[], 1) IS NULL + OR array_length($7::title_status_t[], 1) = 0 + OR t.title_status = ANY($7::title_status_t[]) ) - AND ($7::float IS NULL OR t.rating >= $7::float) - AND ($8::int IS NULL OR t.release_year = $8::int) - AND ($9::release_season_t IS NULL OR t.release_season = $9::release_season_t) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY t.id, i.id, s.id @@ -464,7 +464,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($10::int, 100) +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { @@ -474,6 +474,7 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -506,6 +507,7 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, + arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -646,21 +648,21 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + $7::title_status_t[] IS NULL + OR array_length($7::title_status_t[], 1) IS NULL + OR array_length($7::title_status_t[], 1) = 0 + OR t.title_status = ANY($7::title_status_t[]) ) AND ( - 'usertitle_statuses'::title_status_t[] IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) + $8::usertitle_status_t[] IS NULL + OR array_length($8::usertitle_status_t[], 1) IS NULL + OR array_length($8::usertitle_status_t[], 1) = 0 + OR t.title_status = ANY($8::usertitle_status_t[]) ) - AND ($7::int IS NULL OR u.rate >= $7::int) - AND ($8::float IS NULL OR t.rating >= $8::float) - AND ($9::int IS NULL OR t.release_year = $9::int) - AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + AND ($9::int IS NULL OR u.rate >= $9::int) + AND ($10::float IS NULL OR t.rating >= $10::float) + AND ($11::int IS NULL OR t.release_year = $11::int) + AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) GROUP BY t.id, i.id, s.id @@ -685,21 +687,23 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($11::int, 100) +LIMIT COALESCE($13::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { @@ -734,6 +738,8 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara arg.CursorID, arg.CursorRating, arg.Word, + arg.TitleStatuses, + arg.UsertitleStatuses, arg.Rate, arg.Rating, arg.ReleaseYear, From 1d65833b8a14c7de044bcf82aaed15d3a2303ee6 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 06:56:42 +0300 Subject: [PATCH 78/97] fix --- modules/backend/handlers/common.go | 5 ++++- modules/backend/handlers/titles.go | 3 ++- modules/backend/handlers/users.go | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index e22ce3f..e233f98 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -21,7 +21,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_title := oapi.Title{ EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, // EpisodesLen: &episodes_lens, Id: title.ID, // Poster: &oapi_image, @@ -41,6 +41,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if err != nil { return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } + oapi_title.TitleNames = title_names if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) @@ -56,6 +57,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if err != nil { return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) } + oapi_title.Tags = oapi_tag_names var oapi_studio oapi.Studio if title.StudioName != nil { @@ -80,6 +82,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_image.ImagePath = title.TitleImagePath oapi_image.StorageType = &title.TitleStorageType } + oapi_title.Poster = &oapi_image var release_season oapi.ReleaseSeason if title.ReleaseSeason != nil { diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 054b745..ec9426c 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -224,7 +224,8 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // EpisodesLen: title.EpisodesLen, TitleStorageType: title.TitleStorageType, TitleImagePath: title.TitleImagePath, - TagNames: title.TitleNames, + TitleNames: title.TitleNames, + TagNames: title.TagNames, StudioName: title.StudioName, // StudioIllustID: title.StudioIllustID, // StudioDesc: title.StudioDesc, diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3a271d7..d3848f4 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -157,8 +157,9 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o // EpisodesLen: title.EpisodesLen, TitleStorageType: t.TitleStorageType, TitleImagePath: t.TitleImagePath, - TagNames: t.TitleNames, StudioName: t.StudioName, + TitleNames: t.TitleNames, + TagNames: t.TagNames, // StudioIllustID: title.StudioIllustID, // StudioDesc: title.StudioDesc, // StudioStorageType: title.StudioStorageType, From e999534b3f0dc3a5a83180e037c256cb80160837 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 08:31:55 +0300 Subject: [PATCH 79/97] feat: UpdateUser implemented --- api/paths/users-id.yaml | 76 ++++++++++++++++++++++++++++ modules/backend/handlers/users.go | 48 +++++++++++++++++- modules/backend/queries.sql | 18 +++---- sql/queries.sql.go | 84 ++++++++++++++++++++++--------- 4 files changed, 193 insertions(+), 33 deletions(-) diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 0acdb81..06f4a19 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -24,3 +24,79 @@ get: description: Request params are not correct '500': description: Unknown server error + +patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: Username (alphanumeric + `_` or `-`, 3–16 chars) + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '400': + description: Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON) + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights) + '404': + description: User not found + '409': + description: Conflict — e.g., requested `nickname` or `mail` already taken by another user + '422': + description: Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`) + '500': + description: Unknown server error diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d3848f4..781911f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -33,7 +33,7 @@ func mapUser(u sqlc.GetUserByIDRow) oapi.User { CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, - Mail: (*types.Email)(u.Mail), + Mail: StringToEmail(u.Mail), Nickname: u.Nickname, UserDesc: u.UserDesc, } @@ -270,3 +270,49 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } + +func EmailToStringPtr(e *types.Email) *string { + if e == nil { + return nil + } + s := string(*e) + return &s +} + +func StringToEmail(e *string) *types.Email { + if e == nil { + return nil + } + s := types.Email(*e) + return &s +} + +// UpdateUser implements oapi.StrictServerInterface. +func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestObject) (oapi.UpdateUserResponseObject, error) { + + params := sqlc.UpdateUserParams{ + AvatarID: request.Body.AvatarId, + DispName: request.Body.DispName, + UserDesc: request.Body.UserDesc, + Mail: EmailToStringPtr(request.Body.Mail), + UserID: request.UserId, + } + + user, err := s.db.UpdateUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUser500Response{}, nil + } + + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + AvatarId: user.AvatarID, + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + + return oapi.UpdateUser200JSONResponse(oapi_user), nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index dc81da9..0bf1b86 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -58,15 +58,15 @@ RETURNING id, tag_names; -- VALUES ($1, $2, $3, $4, $5, $6, $7) -- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; --- -- name: UpdateUser :one --- UPDATE users --- SET --- avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), --- disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), --- user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), --- passhash = COALESCE(sqlc.narg('passhash'), passhash) --- WHERE user_id = sqlc.arg('user_id') --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), + disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), + user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), + mail = COALESCE(sqlc.narg('mail'), mail) +WHERE id = sqlc.arg('user_id') +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- -- name: DeleteUser :exec -- DELETE FROM users diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 2df4da8..cac5543 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -114,9 +114,6 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er const getTitleByID = `-- name: GetTitleByID :one - - - SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, @@ -167,26 +164,6 @@ type GetTitleByIDRow struct { StudioImagePath *string `json:"studio_image_path"` } -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: UpdateUser :one -// UPDATE users -// SET -// -// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), -// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), -// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), -// passhash = COALESCE(sqlc.narg('passhash'), passhash) -// -// WHERE user_id = sqlc.arg('user_id') -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; // -- name: DeleteUser :exec // DELETE FROM users // WHERE user_id = $1; @@ -784,3 +761,64 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara } return items, nil } + +const updateUser = `-- name: UpdateUser :one + + +UPDATE users +SET + avatar_id = COALESCE($1, avatar_id), + disp_name = COALESCE($2, disp_name), + user_desc = COALESCE($3, user_desc), + mail = COALESCE($4, mail) +WHERE id = $5 +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail +` + +type UpdateUserParams struct { + AvatarID *int64 `json:"avatar_id"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + Mail *string `json:"mail"` + UserID int64 `json:"user_id"` +} + +type UpdateUserRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + Mail *string `json:"mail"` +} + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, + arg.AvatarID, + arg.DispName, + arg.UserDesc, + arg.Mail, + arg.UserID, + ) + var i UpdateUserRow + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.Mail, + ) + return i, err +} From 15a681c62275a6281cb62fec949c7a214b638baa Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 09:04:40 +0300 Subject: [PATCH 80/97] feat: trigger for ctime on usertitle update --- sql/migrations/000001_init.up.sql | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index e6ed628..0a2fd71 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -87,7 +87,7 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id), - ctime timestamptz + ctime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); @@ -169,4 +169,17 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); + +CREATE OR REPLACE FUNCTION set_ctime() +RETURNS TRIGGER AS $$ +BEGIN + NEW.ctime = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_ctime_on_update +BEFORE UPDATE ON usertitles +FOR EACH ROW +EXECUTE FUNCTION set_ctime(); \ No newline at end of file From 76df4d859295666041239d4766332dc87cc50194 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 09:34:05 +0300 Subject: [PATCH 81/97] feat: AddUserTitle implemented --- api/_build/openapi.yaml | 141 +++++++++++++- api/api.gen.go | 313 +++++++++++++++++++++++++++++- api/openapi.yaml | 3 +- api/paths/users-id-titles.yaml | 52 +++++ api/schemas/UserTitleMini.yaml | 24 +++ api/schemas/updateUser.yaml | 26 +++ modules/backend/handlers/users.go | 72 ++++++- modules/backend/queries.sql | 16 +- sql/models.go | 12 +- sql/queries.sql.go | 129 +++++++++--- 10 files changed, 749 insertions(+), 39 deletions(-) create mode 100644 api/schemas/UserTitleMini.yaml create mode 100644 api/schemas/updateUser.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 215eabc..aa96593 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -141,7 +141,82 @@ paths: description: User not found '500': description: Unknown server error - '/users/{user_id}/titles/': + patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: 'Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)' + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: 'Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)' + '404': + description: User not found + '409': + description: 'Conflict — e.g., requested `nickname` or `mail` already taken by another user' + '422': + description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' + '500': + description: Unknown server error + '/users/{user_id}/titles': get: summary: Get user titles parameters: @@ -231,6 +306,70 @@ paths: description: Request params are not correct '500': description: Unknown server error + post: + summary: Add a title to a user + description: 'User adding title to list af watched, status required' + operationId: addUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitle' + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + type: object + properties: + data: + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: false + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error components: parameters: cursor: diff --git a/api/api.gen.go b/api/api.gen.go index dcc2f89..b092742 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -181,6 +181,24 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserJSONBody defines parameters for UpdateUser. +type UpdateUserJSONBody struct { + // AvatarId ID of the user avatar (references `images.id`); set to `null` to remove avatar + AvatarId *int64 `json:"avatar_id"` + + // DispName Display name + DispName *string `json:"disp_name,omitempty"` + + // Mail User email (must be unique and valid) + Mail *openapi_types.Email `json:"mail,omitempty"` + + // Nickname Username (alphanumeric + `_` or `-`, 3–16 chars) + Nickname *string `json:"nickname,omitempty"` + + // UserDesc User description / bio + UserDesc *string `json:"user_desc,omitempty"` +} + // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -199,6 +217,12 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. +type UpdateUserJSONRequestBody UpdateUserJSONBody + +// AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. +type AddUserTitleJSONRequestBody = UserTitle + // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -591,9 +615,15 @@ type ServerInterface interface { // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(c *gin.Context, userId int64) // Get user titles - // (GET /users/{user_id}/titles/) + // (GET /users/{user_id}/titles) GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(c *gin.Context, userId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -781,6 +811,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { siw.Handler.GetUsersUserId(c, userId, params) } +// UpdateUser operation middleware +func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUser(c, userId) +} + // GetUsersUserIdTitles operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { @@ -904,6 +958,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { siw.Handler.GetUsersUserIdTitles(c, userId, params) } +// AddUserTitle operation middleware +func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.AddUserTitle(c, userId) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -934,7 +1012,9 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) - router.GET(options.BaseURL+"/users/:user_id/titles/", wrapper.GetUsersUserIdTitles) + router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) } type GetTitlesRequestObject struct { @@ -1075,6 +1155,80 @@ func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type UpdateUserRequestObject struct { + UserId int64 `json:"user_id"` + Body *UpdateUserJSONRequestBody +} + +type UpdateUserResponseObject interface { + VisitUpdateUserResponse(w http.ResponseWriter) error +} + +type UpdateUser200JSONResponse User + +func (response UpdateUser200JSONResponse) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUser400Response struct { +} + +func (response UpdateUser400Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUser401Response struct { +} + +func (response UpdateUser401Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUser403Response struct { +} + +func (response UpdateUser403Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUser404Response struct { +} + +func (response UpdateUser404Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUser409Response struct { +} + +func (response UpdateUser409Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type UpdateUser422Response struct { +} + +func (response UpdateUser422Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(422) + return nil +} + +type UpdateUser500Response struct { +} + +func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersUserIdTitlesRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdTitlesParams @@ -1120,6 +1274,83 @@ func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesRespons return nil } +type AddUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + Body *AddUserTitleJSONRequestBody +} + +type AddUserTitleResponseObject interface { + VisitAddUserTitleResponse(w http.ResponseWriter) error +} + +type AddUserTitle200JSONResponse struct { + Data *struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + } `json:"data,omitempty"` +} + +func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AddUserTitle400Response struct { +} + +func (response AddUserTitle400Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type AddUserTitle401Response struct { +} + +func (response AddUserTitle401Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type AddUserTitle403Response struct { +} + +func (response AddUserTitle403Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type AddUserTitle404Response struct { +} + +func (response AddUserTitle404Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type AddUserTitle409Response struct { +} + +func (response AddUserTitle409Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type AddUserTitle500Response struct { +} + +func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles @@ -1131,9 +1362,15 @@ type StrictServerInterface interface { // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) // Get user titles - // (GET /users/{user_id}/titles/) + // (GET /users/{user_id}/titles) GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -1231,6 +1468,41 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params } } +// UpdateUser operation middleware +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { + var request UpdateUserRequestObject + + request.UserId = userId + + var body UpdateUserJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUser(ctx, request.(UpdateUserRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUser") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserResponseObject); ok { + if err := validResponse.VisitUpdateUserResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserIdTitles operation middleware func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { var request GetUsersUserIdTitlesRequestObject @@ -1258,3 +1530,38 @@ func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, p ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// AddUserTitle operation middleware +func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { + var request AddUserTitleRequestObject + + request.UserId = userId + + var body AddUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.AddUserTitle(ctx, request.(AddUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AddUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(AddUserTitleResponseObject); ok { + if err := validResponse.VisitAddUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index c8bdbc4..7da26f8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -13,8 +13,9 @@ paths: $ref: "./paths/titles-id.yaml" /users/{user_id}: $ref: "./paths/users-id.yaml" - /users/{user_id}/titles/: + /users/{user_id}/titles: $ref: "./paths/users-id-titles.yaml" + components: parameters: $ref: "./parameters/_index.yaml" diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index a76cc40..7e6ac5e 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -87,3 +87,55 @@ get: description: Request params are not correct '500': description: Unknown server error + +post: + summary: Add a title to a user + description: User adding title to list af watched, status required + operationId: addUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/UserTitle.yaml' + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + type: object + properties: + # success: + # type: boolean + # example: true + # error: + # type: string + # nullable: true + # example: null + data: + $ref: '../schemas/UserTitleMini.yaml' + # required: + # - success + # - error + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error \ No newline at end of file diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml new file mode 100644 index 0000000..9e45e95 --- /dev/null +++ b/api/schemas/UserTitleMini.yaml @@ -0,0 +1,24 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time +additionalProperties: false diff --git a/api/schemas/updateUser.yaml b/api/schemas/updateUser.yaml new file mode 100644 index 0000000..e1d77af --- /dev/null +++ b/api/schemas/updateUser.yaml @@ -0,0 +1,26 @@ +type: object +properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. +additionalProperties: false +description: Only provided fields are updated. Omitted fields remain unchanged. \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 781911f..23fda62 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -125,10 +125,32 @@ func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, e return sqlc_status, nil } +func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { + var sqlc_status sqlc.UsertitleStatusT + if s == nil { + return nil, nil + } + + switch *s { + case oapi.UserTitleStatusFinished: + sqlc_status = sqlc.UsertitleStatusTFinished + case oapi.UserTitleStatusInProgress: + sqlc_status = sqlc.UsertitleStatusTInProgress + case oapi.UserTitleStatusDropped: + sqlc_status = sqlc.UsertitleStatusTDropped + case oapi.UserTitleStatusPlanned: + sqlc_status = sqlc.UsertitleStatusTPlanned + default: + return nil, fmt.Errorf("unexpected tittle status: %s", s) + } + + return &sqlc_status, nil +} + func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { oapi_usertitle := oapi.UserTitle{ - Ctime: sqlDate2oapi(t.UserCtime), + Ctime: &t.UserCtime, Rate: t.UserRate, ReviewId: t.ReviewID, // Status: , @@ -316,3 +338,51 @@ func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestOb return oapi.UpdateUser200JSONResponse(oapi_user), nil } + +func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { + + status, err := UserTitleStatus2Sqlc1(&request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle400Response{}, nil + } + + params := sqlc.InsertUserTitleParams{ + UserID: request.UserId, + TitleID: request.Body.Title.Id, + Status: *status, + Rate: request.Body.Rate, + ReviewID: request.Body.ReviewId, + } + + user_title, err := s.db.InsertUserTitle(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + oapi_usertitle := struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status oapi.UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + }{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.AddUserTitle200JSONResponse{Data: &oapi_usertitle}, nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 0bf1b86..450e0a7 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -412,7 +412,7 @@ WHERE review_id = sqlc.arg('review_id')::bigint; -- DELETE FROM reviews -- WHERE review_id = $1; --- name: ListReviewsByTitle :many +-- -- name: ListReviewsByTitle :many -- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -- FROM reviews -- WHERE title_id = $1 @@ -438,10 +438,16 @@ WHERE review_id = sqlc.arg('review_id')::bigint; -- ORDER BY usertitle_id -- LIMIT $2 OFFSET $3; --- -- name: CreateUserTitle :one --- INSERT INTO usertitles (user_id, title_id, status, rate, review_id) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING usertitle_id, user_id, title_id, status, rate, review_id; +-- name: InsertUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + sqlc.arg('user_id')::bigint, + sqlc.arg('title_id')::bigint, + sqlc.arg('status')::usertitle_status_t, + sqlc.narg('rate')::int, + sqlc.narg('review_id')::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime; -- -- name: UpdateUserTitle :one -- UPDATE usertitles diff --git a/sql/models.go b/sql/models.go index 36d4c07..842d58c 100644 --- a/sql/models.go +++ b/sql/models.go @@ -277,10 +277,10 @@ type User struct { } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime time.Time `json:"ctime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index cac5543..fa44808 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -9,8 +9,6 @@ import ( "context" "encoding/json" "time" - - "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -317,6 +315,93 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams return i, err } +const insertUserTitle = `-- name: InsertUserTitle :one + + + + + + + +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + $1::bigint, + $2::bigint, + $3::usertitle_status_t, + $4::int, + $5::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type InsertUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` +} + +// -- name: CreateReview :one +// INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +// VALUES ($1, $2, $3, $4, $5) +// RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; +// -- name: UpdateReview :one +// UPDATE reviews +// SET +// +// image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), +// review_text = COALESCE(sqlc.narg('review_text'), review_text) +// +// WHERE review_id = sqlc.arg('review_id') +// RETURNING *; +// -- name: DeleteReview :exec +// DELETE FROM reviews +// WHERE review_id = $1; +// +// -- name: ListReviewsByTitle :many +// +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE title_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: ListReviewsByUser :many +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE user_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: GetUserTitle :one +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 AND title_id = $2; +// -- name: ListUserTitles :many +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 +// ORDER BY usertitle_id +// LIMIT $2 OFFSET $3; +func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, insertUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + const searchTitles = `-- name: SearchTitles :many SELECT t.id as id, @@ -684,26 +769,26 @@ type SearchUserTitlesParams struct { } type SearchUserTitlesRow struct { - ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - UserID int64 `json:"user_id"` - UsertitleStatus UsertitleStatusT `json:"usertitle_status"` - UserRate *int32 `json:"user_rate"` - ReviewID *int64 `json:"review_id"` - UserCtime pgtype.Timestamptz `json:"user_ctime"` - TitleStorageType string `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime time.Time `json:"user_ctime"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } // 100 is default limit From cea7cd3cd89585787e8e8a2a189035ac30a33b20 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 01:56:48 +0300 Subject: [PATCH 82/97] fix: delete logic improved --- sql/migrations/000001_init.up.sql | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 0a2fd71..437a99f 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -28,8 +28,8 @@ CREATE TABLE reviews ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data text NOT NULL, rating int CHECK (rating >= 0 AND rating <= 10), - user_id bigint REFERENCES users (id), - title_id bigint REFERENCES titles (id), + user_id bigint REFERENCES users (id) ON DELETE SET NULL, + title_id bigint REFERENCES titles (id) ON DELETE CASCADE, created_at timestamptz DEFAULT NOW() ); @@ -41,20 +41,20 @@ CREATE TABLE review_images ( CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - avatar_id bigint REFERENCES images (id), + avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, passhash text NOT NULL, mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), disp_name text, user_desc text, - creation_date timestamptz NOT NULL, + creation_date timestamptz NOT NULL DEFAULT NOW(), last_login timestamptz ); CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, - illust_id bigint REFERENCES images (id), + illust_id bigint REFERENCES images (id) ON DELETE SET NULL, studio_desc text ); @@ -64,7 +64,7 @@ CREATE TABLE titles ( -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, studio_id bigint NOT NULL REFERENCES studios (id), - poster_id bigint REFERENCES images (id), + poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, rating float CHECK (rating >= 0 AND rating <= 10), rating_count int CHECK (rating_count >= 0), @@ -82,19 +82,19 @@ CREATE TABLE titles ( CREATE TABLE usertitles ( PRIMARY KEY (user_id, title_id), - user_id bigint NOT NULL REFERENCES users (id), - title_id bigint NOT NULL REFERENCES titles (id), + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_id bigint REFERENCES reviews (id), + review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, ctime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); CREATE TABLE title_tags ( PRIMARY KEY (title_id, tag_id), - title_id bigint NOT NULL REFERENCES titles (id), - tag_id bigint NOT NULL REFERENCES tags (id) + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE ); CREATE TABLE signals ( @@ -180,6 +180,6 @@ END; $$ LANGUAGE plpgsql; CREATE TRIGGER set_ctime_on_update -BEFORE UPDATE ON usertitles +AFTER UPDATE ON usertitles FOR EACH ROW EXECUTE FUNCTION set_ctime(); \ No newline at end of file From f3fa41382ae63e5e2583081549dc4f62a4959ce4 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 02:19:30 +0300 Subject: [PATCH 83/97] fix: topology sort --- sql/migrations/000001_init.up.sql | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 437a99f..392dcde 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -24,21 +24,6 @@ CREATE TABLE images ( image_path text UNIQUE NOT NULL ); -CREATE TABLE reviews ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - data text NOT NULL, - rating int CHECK (rating >= 0 AND rating <= 10), - user_id bigint REFERENCES users (id) ON DELETE SET NULL, - title_id bigint REFERENCES titles (id) ON DELETE CASCADE, - created_at timestamptz DEFAULT NOW() -); - -CREATE TABLE review_images ( - PRIMARY KEY (review_id, image_id), - review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, - image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE -); - CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, @@ -51,6 +36,8 @@ CREATE TABLE users ( last_login timestamptz ); + + CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, @@ -80,6 +67,21 @@ CREATE TABLE titles ( AND episodes_aired <= episodes_all)) ); +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + user_id bigint REFERENCES users (id) ON DELETE SET NULL, + title_id bigint REFERENCES titles (id) ON DELETE CASCADE, + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + CREATE TABLE usertitles ( PRIMARY KEY (user_id, title_id), user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, From ed79c71273628bc3e3498af53270cd543277e32b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 02:24:17 +0300 Subject: [PATCH 84/97] fix: topology sort. again --- sql/migrations/000001_init.up.sql | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 392dcde..18087cd 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,10 +1,10 @@ -- TODO: -- maybe jsonb constraints --- clean unused images +-- clea('finished', 'ongoing', 'planned'); +CREATE TYPE release_seasn unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); -CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); -CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); +CREATE TYPE title_status_t AS ENUM on_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE providers ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, @@ -107,17 +107,17 @@ CREATE TABLE signals ( pending boolean NOT NULL ); +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL +); + CREATE TABLE external_ids ( user_id bigint NOT NULL REFERENCES users (id), service_id bigint REFERENCES external_services (id), external_id text NOT NULL ); -CREATE TABLE external_services ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text UNIQUE NOT NULL -); - -- Functions CREATE OR REPLACE FUNCTION update_title_rating() RETURNS TRIGGER AS $$ From 9f74c9eeb62077062620b59e40cc195ed6929173 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 02:27:22 +0300 Subject: [PATCH 85/97] fix --- sql/migrations/000001_init.up.sql | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 18087cd..f8781de 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,10 +1,7 @@ --- TODO: --- maybe jsonb constraints --- clea('finished', 'ongoing', 'planned'); -CREATE TYPE release_seasn unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); -CREATE TYPE title_status_t AS ENUM on_t AS ENUM ('winter', 'spring', 'summer', 'fall'); +CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); +CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE providers ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, From 4c7d03cddc2d24ccf231d2cc2f31330f291a9e4f Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 03:20:39 +0300 Subject: [PATCH 86/97] feat: --- api/_build/openapi.yaml | 4 ++++ api/api.gen.go | 17 ++++++++++++++--- api/schemas/Image.yaml | 2 +- api/schemas/enums/StorageType.yaml | 5 +++++ modules/backend/handlers/users.go | 2 +- modules/backend/queries.sql | 2 +- sql/sqlc.yaml | 2 ++ 7 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 api/schemas/enums/StorageType.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index aa96593..d2f231d 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -412,6 +412,10 @@ components: format: int64 storage_type: type: string + description: Image storage type + enum: + - s3 + - local image_path: type: string TitleStatus: diff --git a/api/api.gen.go b/api/api.gen.go index b092742..5e0ddb5 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,6 +16,12 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for ImageStorageType. +const ( + Local ImageStorageType = "local" + S3 ImageStorageType = "s3" +) + // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -55,11 +61,16 @@ type CursorObj struct { // Image defines model for Image. type Image struct { - Id *int64 `json:"id,omitempty"` - ImagePath *string `json:"image_path,omitempty"` - StorageType *string `json:"storage_type,omitempty"` + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + + // StorageType Image storage type + StorageType *ImageStorageType `json:"storage_type,omitempty"` } +// ImageStorageType Image storage type +type ImageStorageType string + // ReleaseSeason Title release season type ReleaseSeason string diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml index 4ae3cb7..3fb520b 100644 --- a/api/schemas/Image.yaml +++ b/api/schemas/Image.yaml @@ -5,6 +5,6 @@ properties: type: integer format: int64 storage_type: - type: string + $ref: './enums/StorageType.yaml' image_path: type: string diff --git a/api/schemas/enums/StorageType.yaml b/api/schemas/enums/StorageType.yaml new file mode 100644 index 0000000..1984a87 --- /dev/null +++ b/api/schemas/enums/StorageType.yaml @@ -0,0 +1,5 @@ +type: string +description: Image storage type +enum: + - s3 + - local \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 23fda62..9204eb9 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -141,7 +141,7 @@ func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, err case oapi.UserTitleStatusPlanned: sqlc_status = sqlc.UsertitleStatusTPlanned default: - return nil, fmt.Errorf("unexpected tittle status: %s", s) + return nil, fmt.Errorf("unexpected tittle status: %s", *s) } return &sqlc_status, nil diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 450e0a7..b9e05c1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -334,7 +334,7 @@ WHERE sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0 - OR t.title_status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) + OR u.status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 3338c35..f26cf92 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,6 +14,8 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - column: "titles.title_storage_type" + go_type: "*string" - db_type: "jsonb" go_type: "encoding/json.RawMessage" - db_type: "uuid" From 673ce48fac5c17f4aab157344cc8fce86e45e124 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 03:55:23 +0300 Subject: [PATCH 87/97] fix: bad types from sql --- modules/backend/handlers/common.go | 30 ++++++++++- modules/backend/handlers/titles.go | 85 ++++++++++++++++-------------- modules/backend/queries.sql | 8 +-- sql/queries.sql.go | 18 +++---- sql/sqlc.yaml | 7 ++- 5 files changed, 91 insertions(+), 57 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index e233f98..73efc42 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -17,6 +17,22 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } +func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { + if s == nil { + return nil, nil + } + var t oapi.ImageStorageType + switch *s { + case sqlc.StorageTypeTLocal: + t = oapi.Local + case sqlc.StorageTypeTS3: + t = oapi.S3 + default: + return nil, fmt.Errorf("unexpected storage type: %s", *s) + } + return &t, nil +} + func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { oapi_title := oapi.Title{ @@ -70,7 +86,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_studio.Poster = &oapi.Image{} oapi_studio.Poster.Id = title.StudioIllustID oapi_studio.Poster.ImagePath = title.StudioImagePath - oapi_studio.Poster.StorageType = &title.StudioStorageType + + s, err := sql2StorageType(title.StudioStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, studio storage type: %v", err) + } + oapi_studio.Poster.StorageType = s + } } oapi_title.Studio = &oapi_studio @@ -80,7 +102,11 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if title.PosterID != nil { oapi_image.Id = title.PosterID oapi_image.ImagePath = title.TitleImagePath - oapi_image.StorageType = &title.TitleStorageType + s, err := sql2StorageType(title.TitleStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, title starage type: %v", err) + } + oapi_image.StorageType = s } oapi_title.Poster = &oapi_image diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index ec9426c..c67177f 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -81,56 +81,56 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return oapi_tag_names, nil } -func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { +// func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image oapi.Image +// var oapi_image oapi.Image - sqlc_image, err := s.db.GetImageByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil //todo: error reference in db - } - return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) - } +// sqlc_image, err := s.db.GetImageByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil //todo: error reference in db +// } +// return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) +// } - //can cast and dont use brain cause all this fields required in image table - oapi_image.Id = &sqlc_image.ID - oapi_image.ImagePath = &sqlc_image.ImagePath - storageTypeStr := string(sqlc_image.StorageType) - oapi_image.StorageType = &storageTypeStr +// //can cast and dont use brain cause all this fields required in image table +// oapi_image.Id = &sqlc_image.ID +// oapi_image.ImagePath = &sqlc_image.ImagePath +// storageTypeStr := string(sqlc_image.StorageType) +// oapi_image.StorageType = string(storageTypeStr) - return &oapi_image, nil -} +// return &oapi_image, nil +// } -func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { +// func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { - var oapi_studio oapi.Studio +// var oapi_studio oapi.Studio - sqlc_studio, err := s.db.GetStudioByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil - } - return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) - } +// sqlc_studio, err := s.db.GetStudioByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil +// } +// return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) +// } - oapi_studio.Id = sqlc_studio.ID - oapi_studio.Name = sqlc_studio.StudioName - oapi_studio.Description = sqlc_studio.StudioDesc +// oapi_studio.Id = sqlc_studio.ID +// oapi_studio.Name = sqlc_studio.StudioName +// oapi_studio.Description = sqlc_studio.StudioDesc - if sqlc_studio.IllustID == nil { - return &oapi_studio, nil - } - oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) - if err != nil { - return &oapi_studio, fmt.Errorf("GetImage: %v", err) - } - if oapi_illust != nil { - oapi_studio.Poster = oapi_illust - } +// if sqlc_studio.IllustID == nil { +// return &oapi_studio, nil +// } +// oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) +// if err != nil { +// return &oapi_studio, fmt.Errorf("GetImage: %v", err) +// } +// if oapi_illust != nil { +// oapi_studio.Poster = oapi_illust +// } - return &oapi_studio, nil -} +// return &oapi_studio, nil +// } func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { var oapi_title oapi.Title @@ -233,6 +233,11 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // StudioImagePath: title.StudioImagePath, } + // if title.TitleStorageType != nil { + // s := *title.TitleStorageType + // _title.TitleStorageType = string(s) + // } + t, err := s.mapTitle(ctx, _title) if err != nil { log.Errorf("%v", err) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b9e05c1..87d7755 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -76,7 +76,7 @@ RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- sqlc.struct: TitlesFull SELECT t.*, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -85,7 +85,7 @@ SELECT s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, + si.storage_type as studio_storage_type, si.image_path as studio_image_path FROM titles as t @@ -112,7 +112,7 @@ SELECT t.season as season, t.episodes_aired as episodes_aired, t.episodes_all as episodes_all, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -243,7 +243,7 @@ SELECT u.rate as user_rate, u.review_id as review_id, u.ctime as user_ctime, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), diff --git a/sql/queries.sql.go b/sql/queries.sql.go index fa44808..5236f1f 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -114,7 +114,7 @@ const getTitleByID = `-- name: GetTitleByID :one SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -123,7 +123,7 @@ SELECT s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, + si.storage_type as studio_storage_type, si.image_path as studio_image_path FROM titles as t @@ -152,13 +152,13 @@ type GetTitleByIDRow struct { EpisodesAired *int32 `json:"episodes_aired"` EpisodesAll *int32 `json:"episodes_all"` EpisodesLen []byte `json:"episodes_len"` - TitleStorageType string `json:"title_storage_type"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` - StudioStorageType string `json:"studio_storage_type"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` StudioImagePath *string `json:"studio_image_path"` } @@ -415,7 +415,7 @@ SELECT t.season as season, t.episodes_aired as episodes_aired, t.episodes_all as episodes_all, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -555,7 +555,7 @@ type SearchTitlesRow struct { Season *int32 `json:"season"` EpisodesAired *int32 `json:"episodes_aired"` EpisodesAll *int32 `json:"episodes_all"` - TitleStorageType string `json:"title_storage_type"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` @@ -628,7 +628,7 @@ SELECT u.rate as user_rate, u.review_id as review_id, u.ctime as user_ctime, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -719,7 +719,7 @@ WHERE $8::usertitle_status_t[] IS NULL OR array_length($8::usertitle_status_t[], 1) IS NULL OR array_length($8::usertitle_status_t[], 1) = 0 - OR t.title_status = ANY($8::usertitle_status_t[]) + OR u.status = ANY($8::usertitle_status_t[]) ) AND ($9::int IS NULL OR u.rate >= $9::int) AND ($10::float IS NULL OR t.rating >= $10::float) @@ -785,7 +785,7 @@ type SearchUserTitlesRow struct { UserRate *int32 `json:"user_rate"` ReviewID *int64 `json:"review_id"` UserCtime time.Time `json:"user_ctime"` - TitleStorageType string `json:"title_storage_type"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f26cf92..de67bcf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,8 +14,11 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: - - column: "titles.title_storage_type" - go_type: "*string" + - db_type: "storage_type_t" + nullable: true + go_type: + type: "StorageTypeT" + pointer: true - db_type: "jsonb" go_type: "encoding/json.RawMessage" - db_type: "uuid" From 3aafab36c266b22272981de050d37b58e80ec240 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 04:15:46 +0300 Subject: [PATCH 88/97] feat: now GetUser returnes all the image info --- api/_build/openapi.yaml | 7 ++----- api/api.gen.go | 6 ++---- api/schemas/User.yaml | 7 ++----- modules/backend/handlers/users.go | 26 +++++++++++++++++------ modules/backend/queries.sql | 16 ++++++++++++--- sql/queries.sql.go | 34 ++++++++++++++++++++++--------- 6 files changed, 63 insertions(+), 33 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index d2f231d..10cb7c7 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -552,11 +552,8 @@ components: format: int64 description: Unique user ID (primary key) example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null + image: + $ref: '#/components/schemas/Image' mail: type: string format: email diff --git a/api/api.gen.go b/api/api.gen.go index 5e0ddb5..02d389e 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -124,9 +124,6 @@ type TitleStatus string // User defines model for User. type User struct { - // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id,omitempty"` - // CreationDate Timestamp when the user was created CreationDate *time.Time `json:"creation_date,omitempty"` @@ -134,7 +131,8 @@ type User struct { DispName *string `json:"disp_name,omitempty"` // Id Unique user ID (primary key) - Id *int64 `json:"id,omitempty"` + Id *int64 `json:"id,omitempty"` + Image *Image `json:"image,omitempty"` // Mail User email Mail *openapi_types.Email `json:"mail,omitempty"` diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml index 8b4d88d..4e53534 100644 --- a/api/schemas/User.yaml +++ b/api/schemas/User.yaml @@ -5,11 +5,8 @@ properties: format: int64 description: Unique user ID (primary key) example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null + image: + $ref: '../schemas/Image.yaml' mail: type: string format: email diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 9204eb9..96e7251 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -27,16 +27,25 @@ import ( // return int32(i), err // } -func mapUser(u sqlc.GetUserByIDRow) oapi.User { +func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { + i := oapi.Image{ + Id: u.AvatarID, + ImagePath: u.ImagePath, + } + s, err := sql2StorageType(u.StorageType) + if err != nil { + return oapi.User{}, fmt.Errorf("mapUser, storage type: %v", err) + } + i.StorageType = s return oapi.User{ - AvatarId: u.AvatarID, + Image: &i, CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, Mail: StringToEmail(u.Mail), Nickname: u.Nickname, UserDesc: u.UserDesc, - } + }, nil } func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { @@ -44,14 +53,19 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque if err != nil { return oapi.GetUsersUserId404Response{}, nil } - user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + _user, err := s.db.GetUserByID(context.TODO(), int64(userID)) if err != nil { if err == pgx.ErrNoRows { return oapi.GetUsersUserId404Response{}, nil } return nil, err } - return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil + user, err := mapUser(_user) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserId500Response{}, err + } + return oapi.GetUsersUserId200JSONResponse(user), nil } func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { @@ -327,7 +341,7 @@ func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestOb } oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit - AvatarId: user.AvatarID, + // AvatarId: user.AvatarID, CreationDate: &user.CreationDate, DispName: user.DispName, Id: &user.ID, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 87d7755..90484db 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -9,9 +9,19 @@ VALUES ($1, $2) RETURNING id, storage_type, image_path; -- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1; +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE id = sqlc.arg('id')::bigint; -- name: GetStudioByID :one diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 5236f1f..d88a041 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -224,19 +224,31 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMe } const getUserByID = `-- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1 +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE id = $1::bigint ` type GetUserByIDRow struct { - ID int64 `json:"id"` - AvatarID *int64 `json:"avatar_id"` - Mail *string `json:"mail"` - Nickname string `json:"nickname"` - DispName *string `json:"disp_name"` - UserDesc *string `json:"user_desc"` - CreationDate time.Time `json:"creation_date"` + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` } func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) { @@ -250,6 +262,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er &i.DispName, &i.UserDesc, &i.CreationDate, + &i.StorageType, + &i.ImagePath, ) return i, err } From 87eb6a6b12e66efd9f31ba461d5d60d7bba41b78 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 25 Nov 2025 04:13:52 +0300 Subject: [PATCH 89/97] feat: signup return username --- auth/auth.gen.go | 6 +++--- auth/openapi-auth.yaml | 14 ++++---------- modules/auth/handlers/handlers.go | 7 +++---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index adb2b06..b24deb5 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,9 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + Error *string `json:"error"` + UserId *string `json:"user_id"` + UserName *string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 913c000..0fe308c 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -59,29 +59,23 @@ paths: type: string format: password responses: + # This one also sets two cookies: access_token and refresh_token "200": description: Sign-in result with JWT - # headers: - # Set-Cookie: - # schema: - # type: array - # items: - # type: string - # explode: true - # style: simple content: application/json: schema: type: object properties: - success: - type: boolean error: type: string nullable: true user_id: type: string nullable: true + user_name: + type: string + nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9b9b0d3..7f675aa 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -78,7 +78,6 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque } err := "" - success := true pass, ok := UserDb[req.Body.Nickname] if !ok || pass != req.Body.Pass { @@ -96,9 +95,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + Error: &err, + UserId: &req.Body.Nickname, + UserName: &req.Body.Nickname, } return result, nil } From 354c577f7db7e6f82e2478aeb46b1e90bae74efa Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 25 Nov 2025 04:33:54 +0300 Subject: [PATCH 90/97] feat: reworked user and login page --- modules/frontend/src/App.tsx | 21 +- modules/frontend/src/api/core/OpenAPI.ts | 2 +- .../src/api/services/DefaultService.ts | 122 +++++++++++- .../frontend/src/auth/services/AuthService.ts | 2 +- .../src/pages/LoginPage/LoginPage.tsx | 14 +- .../src/pages/UsersIdPage/UsersIdPage.tsx | 183 ++++++++++++++++++ 6 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5a25313..3ecfa2d 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,23 +1,34 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UserPage from "./pages/UserPage/UserPage"; +import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { - const username = "nihonium"; + // Получаем username из localStorage + const username = localStorage.getItem("username") || undefined; + const userId = localStorage.getItem("userId"); + return ( <Router> <Header username={username} /> <Routes> - <Route path="/login" element={<LoginPage />} /> {/* <-- маршрут для логина */} - <Route path="/signup" element={<LoginPage />} /> {/* <-- можно использовать тот же компонент для регистрации */} - <Route path="/users/:id" element={<UserPage />} /> + <Route path="/login" element={<LoginPage />} /> + <Route path="/signup" element={<LoginPage />} /> + + {/* /profile рендерит UsersIdPage с id из localStorage */} + <Route + path="/profile" + element={userId ? <UsersIdPage userId={userId} /> : <LoginPage />} + /> + + <Route path="/users/:id" element={<UsersIdPage />} /> <Route path="/titles" element={<TitlesPage />} /> </Routes> </Router> ); }; + export default App; \ No newline at end of file diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 6ce873e..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/api/v1', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 52321b8..bb42012 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -20,7 +20,7 @@ export class DefaultService { * @param sort * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param rating * @param releaseYear * @param releaseSeason @@ -35,7 +35,7 @@ export class DefaultService { sort?: TitleSort, sortForward: boolean = true, word?: string, - status?: TitleStatus, + status?: Array<TitleStatus>, rating?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, @@ -125,45 +125,112 @@ export class DefaultService { }, }); } + /** + * Partially update a user account + * Update selected user profile fields (excluding password). + * Password updates must be done via the dedicated auth-service (`/auth/`). + * Fields not provided in the request body remain unchanged. + * + * @param userId User ID (primary key) + * @param requestBody + * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). + * @throws ApiError + */ + public static updateUser( + userId: number, + requestBody: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }, + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, + 401: `Unauthorized — missing or invalid authentication token`, + 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, + 404: `User not found`, + 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, + 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, + 500: `Unknown server error`, + }, + }); + } /** * Get user titles * @param userId * @param cursor + * @param sort + * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param watchStatus * @param rating + * @param myRate * @param releaseYear * @param releaseSeason * @param limit * @param fields - * @returns UserTitle List of user titles + * @returns any List of user titles * @throws ApiError */ public static getUsersTitles( userId: string, cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, word?: string, - status?: TitleStatus, - watchStatus?: UserTitleStatus, + status?: Array<TitleStatus>, + watchStatus?: Array<UserTitleStatus>, rating?: number, + myRate?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, limit: number = 10, fields: string = 'all', - ): CancelablePromise<Array<UserTitle>> { + ): CancelablePromise<{ + data: Array<UserTitle>; + cursor: CursorObj; + }> { return __request(OpenAPI, { method: 'GET', - url: '/users/{user_id}/titles/', + url: '/users/{user_id}/titles', path: { 'user_id': userId, }, query: { 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, 'word': word, 'status': status, 'watch_status': watchStatus, 'rating': rating, + 'my_rate': myRate, 'release_year': releaseYear, 'release_season': releaseSeason, 'limit': limit, @@ -175,4 +242,43 @@ export class DefaultService { }, }); } + /** + * Add a title to a user + * User adding title to list af watched, status required + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns any Title successfully added to user + * @throws ApiError + */ + public static addUserTitle( + userId: number, + requestBody: UserTitle, + ): CancelablePromise<{ + data?: { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; + }; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to assign titles to this user`, + 404: `User or Title not found`, + 409: `Conflict — title already assigned to user (if applicable)`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index bab9c77..94578d8 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -41,9 +41,9 @@ export class AuthService { pass: string; }, ): CancelablePromise<{ - success?: boolean; error?: string | null; user_id?: string | null; + user_name?: string | null; }> { return __request(OpenAPI, { method: 'POST', diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index dcd6965..89ee88c 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -18,17 +18,19 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { const res = await AuthService.postAuthSignIn({ nickname, pass: password }); - if (res.success) { - // TODO: сохранить JWT в localStorage/cookie - console.log("Logged in user id:", res.user_id); - navigate("/"); // редирект после успешного входа + if (res.user_id && res.user_name) { + // Сохраняем user_id и username в localStorage + localStorage.setItem("userId", res.user_id); + localStorage.setItem("username", res.user_name); + + navigate("/profile"); // редирект на профиль } else { setError(res.error || "Login failed"); } } else { + // SignUp оставляем без сохранения данных const res = await AuthService.postAuthSignUp({ nickname, pass: password }); - if (res.success) { - console.log("User signed up with id:", res.user_id); + if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { setError(res.error || "Sign up failed"); diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx new file mode 100644 index 0000000..b5a8336 --- /dev/null +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -0,0 +1,183 @@ +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; +import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; + +const PAGE_SIZE = 10; + +type UsersIdPageProps = { + userId?: string; +}; + +export default function UsersIdPage({ userId }: UsersIdPageProps) { + const params = useParams(); + const id = userId || params?.id; + + const [user, setUser] = useState<User | null>(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState<string | null>(null); + + // Для списка тайтлов + const [titles, setTitles] = useState<UserTitle[]>([]); + const [nextPage, setNextPage] = useState<UserTitle[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + // --- Получение данных пользователя --- + useEffect(() => { + const fetchUser = async () => { + if (!id) return; + setLoadingUser(true); + try { + const result = await DefaultService.getUsers(id, "all"); + setUser(result); + setErrorUser(null); + } catch (err: any) { + console.error(err); + setErrorUser(err?.message || "Failed to fetch user data"); + } finally { + setLoadingUser(false); + } + }; + fetchUser(); + }, [id]); + + // --- Получение списка тайтлов пользователя --- + const fetchPage = async (cursorObj: CursorObj | null) => { + if (!id) return { items: [], nextCursor: null }; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : ""; + + try { + const result = await DefaultService.getUsersTitles( + id, + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, // status фильтр, можно добавить + undefined, // watchStatus + undefined, // rating + undefined, // myRate + undefined, // releaseYear + undefined, // releaseSeason + PAGE_SIZE, + "all" + ); + + if (!result?.data?.length) return { items: [], nextCursor: null }; + + return { items: result.data, nextCursor: result.cursor ?? null }; + } catch (err: any) { + if (err.status === 204) return { items: [], nextCursor: null }; + throw err; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoadingTitles(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoadingTitles(false); + }; + initLoad(); + }, [id, search, sort, sortForward]); + + const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) setNextPage(next.items); + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + setLoadingMore(false); + }; + + const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + {/* --- Карточка пользователя --- */} + {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} + {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} + {user && ( + <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> + <img src={getAvatarUrl(user.avatar_id)} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> + {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} + {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} + {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} + </div> + )} + + {/* --- Панель поиска, сортировки и лейаута --- */} + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> + </div> + + {/* --- Список тайтлов --- */} + {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} + {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} + + {titles.length > 0 && ( + <> + <ListView<UserTitle> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length === 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} + </div> + ); +} From dbdb52269a5cc1d3055dfca52e0d1db0e3930f26 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 04:42:56 +0300 Subject: [PATCH 91/97] fix --- api/_build/openapi.yaml | 2 + api/api.gen.go | 8 +++ api/paths/users-id-titles.yaml | 2 + modules/backend/handlers/common.go | 4 +- modules/backend/handlers/users.go | 8 ++- modules/backend/queries.sql | 6 +- sql/queries.sql.go | 98 ++++++++++++++++-------------- 7 files changed, 76 insertions(+), 52 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 10cb7c7..6b39558 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -304,6 +304,8 @@ paths: description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error post: diff --git a/api/api.gen.go b/api/api.gen.go index 02d389e..f3e935c 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -1275,6 +1275,14 @@ func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesRespons return nil } +type GetUsersUserIdTitles404Response struct { +} + +func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + type GetUsersUserIdTitles500Response struct { } diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 7e6ac5e..23ea761 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -85,6 +85,8 @@ get: description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 73efc42..2cf2283 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -125,9 +125,9 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. return oapi_title, nil } -func parseInt64(s string) (int32, error) { +func parseInt64(s string) (int64, error) { i, err := strconv.ParseInt(s, 10, 64) - return int32(i), err + return i, err } func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) { diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 96e7251..927c1c1 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -53,7 +53,7 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque if err != nil { return oapi.GetUsersUserId404Response{}, nil } - _user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + _user, err := s.db.GetUserByID(context.TODO(), userID) if err != nil { if err == pgx.ErrNoRows { return oapi.GetUsersUserId404Response{}, nil @@ -243,7 +243,13 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } + userID, err := parseInt64(request.UserId) + if err != nil { + log.Errorf("get user titles: %v", err) + return oapi.GetUsersUserIdTitles404Response{}, err + } params := sqlc.SearchUserTitlesParams{ + UserID: userID, Word: word, TitleStatuses: title_statuses, UsertitleStatuses: watch_status, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 90484db..0146b25 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -21,7 +21,7 @@ SELECT i.image_path as image_path FROM users as t LEFT JOIN images as i ON (t.avatar_id = i.id) -WHERE id = sqlc.arg('id')::bigint; +WHERE t.id = sqlc.arg('id')::bigint; -- name: GetStudioByID :one @@ -269,6 +269,8 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE + u.user_id = sqlc.arg('user_id')::bigint + AND CASE WHEN sqlc.arg('forward')::boolean THEN -- forward: greater than cursor (next page) @@ -352,7 +354,7 @@ WHERE AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN diff --git a/sql/queries.sql.go b/sql/queries.sql.go index d88a041..a46da86 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -236,7 +236,7 @@ SELECT i.image_path as image_path FROM users as t LEFT JOIN images as i ON (t.avatar_id = i.id) -WHERE id = $1::bigint +WHERE t.id = $1::bigint ` type GetUserByIDRow struct { @@ -658,43 +658,45 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE + u.user_id = $1::bigint + AND CASE - WHEN $1::boolean THEN + WHEN $2::boolean THEN -- forward: greater than cursor (next page) - CASE $2::text + CASE $3::text WHEN 'year' THEN - ($3::int IS NULL) OR - (t.release_year > $3::int) OR - (t.release_year = $3::int AND t.id > $4::bigint) + ($4::int IS NULL) OR + (t.release_year > $4::int) OR + (t.release_year = $4::int AND t.id > $5::bigint) WHEN 'rating' THEN - ($5::float IS NULL) OR - (t.rating > $5::float) OR - (t.rating = $5::float AND t.id > $4::bigint) + ($6::float IS NULL) OR + (t.rating > $6::float) OR + (t.rating = $6::float AND t.id > $5::bigint) WHEN 'id' THEN - ($4::bigint IS NULL) OR - (t.id > $4::bigint) + ($5::bigint IS NULL) OR + (t.id > $5::bigint) ELSE true -- fallback END ELSE -- backward: less than cursor (prev page) - CASE $2::text + CASE $3::text WHEN 'year' THEN - ($3::int IS NULL) OR - (t.release_year < $3::int) OR - (t.release_year = $3::int AND t.id < $4::bigint) + ($4::int IS NULL) OR + (t.release_year < $4::int) OR + (t.release_year = $4::int AND t.id < $5::bigint) WHEN 'rating' THEN - ($5::float IS NULL) OR - (t.rating < $5::float) OR - (t.rating = $5::float AND t.id < $4::bigint) + ($6::float IS NULL) OR + (t.rating < $6::float) OR + (t.rating = $6::float AND t.id < $5::bigint) WHEN 'id' THEN - ($4::bigint IS NULL) OR - (t.id < $4::bigint) + ($5::bigint IS NULL) OR + (t.id < $5::bigint) ELSE true END @@ -702,7 +704,7 @@ WHERE AND ( CASE - WHEN $6::text IS NOT NULL THEN + WHEN $7::text IS NOT NULL THEN ( SELECT bool_and( EXISTS ( @@ -714,7 +716,7 @@ WHERE FROM unnest( ARRAY( SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($6::text, ' ')) AS w + FROM unnest(string_to_array($7::text, ' ')) AS w WHERE trim(w) <> '' ) ) AS pattern @@ -724,49 +726,50 @@ WHERE ) AND ( - $7::title_status_t[] IS NULL - OR array_length($7::title_status_t[], 1) IS NULL - OR array_length($7::title_status_t[], 1) = 0 - OR t.title_status = ANY($7::title_status_t[]) + $8::title_status_t[] IS NULL + OR array_length($8::title_status_t[], 1) IS NULL + OR array_length($8::title_status_t[], 1) = 0 + OR t.title_status = ANY($8::title_status_t[]) ) AND ( - $8::usertitle_status_t[] IS NULL - OR array_length($8::usertitle_status_t[], 1) IS NULL - OR array_length($8::usertitle_status_t[], 1) = 0 - OR u.status = ANY($8::usertitle_status_t[]) + $9::usertitle_status_t[] IS NULL + OR array_length($9::usertitle_status_t[], 1) IS NULL + OR array_length($9::usertitle_status_t[], 1) = 0 + OR u.status = ANY($9::usertitle_status_t[]) ) - AND ($9::int IS NULL OR u.rate >= $9::int) - AND ($10::float IS NULL OR t.rating >= $10::float) - AND ($11::int IS NULL OR t.release_year = $11::int) - AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + AND ($10::int IS NULL OR u.rate >= $10::int) + AND ($11::float IS NULL OR t.rating >= $11::float) + AND ($12::int IS NULL OR t.release_year = $12::int) + AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t) GROUP BY - t.id, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id ORDER BY - CASE WHEN $1::boolean THEN + CASE WHEN $2::boolean THEN CASE - WHEN $2::text = 'id' THEN t.id - WHEN $2::text = 'year' THEN t.release_year - WHEN $2::text = 'rating' THEN t.rating - WHEN $2::text = 'rate' THEN u.rate + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate END END ASC, - CASE WHEN NOT $1::boolean THEN + CASE WHEN NOT $2::boolean THEN CASE - WHEN $2::text = 'id' THEN t.id - WHEN $2::text = 'year' THEN t.release_year - WHEN $2::text = 'rating' THEN t.rating - WHEN $2::text = 'rate' THEN u.rate + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate END END DESC, - CASE WHEN $2::text <> 'id' THEN t.id END ASC + CASE WHEN $3::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($13::int, 100) +LIMIT COALESCE($14::int, 100) ` type SearchUserTitlesParams struct { + UserID int64 `json:"user_id"` Forward bool `json:"forward"` SortBy string `json:"sort_by"` CursorYear *int32 `json:"cursor_year"` @@ -808,6 +811,7 @@ type SearchUserTitlesRow struct { // 100 is default limit func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, + arg.UserID, arg.Forward, arg.SortBy, arg.CursorYear, From b8bfe01ef578f3db8338de54f2898888d9889fd9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 04:57:07 +0300 Subject: [PATCH 92/97] fix: usertitles status mapping --- modules/backend/handlers/users.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 927c1c1..d800e7a 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -101,14 +101,14 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) { var status oapi.UserTitleStatus - switch status { - case "finished": + switch s { + case sqlc.UsertitleStatusTFinished: status = oapi.UserTitleStatusFinished - case "dropped": + case sqlc.UsertitleStatusTDropped: status = oapi.UserTitleStatusDropped - case "planned": + case sqlc.UsertitleStatusTPlanned: status = oapi.UserTitleStatusPlanned - case "in-progress": + case sqlc.UsertitleStatusTInProgress: status = oapi.UserTitleStatusInProgress default: return status, fmt.Errorf("unexpected tittle status: %s", s) From 51bf7b6f7e4c2bcbd4266f207280aecf05bbb2f9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 25 Nov 2025 05:36:57 +0300 Subject: [PATCH 93/97] fix: UserTitle cards --- api/schemas/UserTitle.yaml | 1 - modules/frontend/src/api/models/Image.ts | 5 ++++- modules/frontend/src/api/models/User.ts | 6 ++--- modules/frontend/src/api/models/UserTitle.ts | 12 +++++++++- .../src/api/services/DefaultService.ts | 1 + .../frontend/src/components/Header/Header.tsx | 2 +- .../cards/UserTitleCardHorizontal.tsx | 22 +++++++++++++++++++ .../components/cards/UserTitleCardSquare.tsx | 22 +++++++++++++++++++ .../frontend/src/pages/UserPage/UserPage.tsx | 4 ++-- .../src/pages/UsersIdPage/UsersIdPage.tsx | 10 ++++----- 10 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx create mode 100644 modules/frontend/src/components/cards/UserTitleCardSquare.tsx diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index 3beaec6..ef619cb 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -20,4 +20,3 @@ properties: ctime: type: string format: date-time -additionalProperties: true diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts index 1317db7..a94de74 100644 --- a/modules/frontend/src/api/models/Image.ts +++ b/modules/frontend/src/api/models/Image.ts @@ -4,7 +4,10 @@ /* eslint-disable */ export type Image = { id?: number; - storage_type?: string; + /** + * Image storage type + */ + storage_type?: 's3' | 'local'; image_path?: string; }; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index cd76dbe..969023f 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -2,15 +2,13 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { Image } from './Image'; export type User = { /** * Unique user ID (primary key) */ id?: number; - /** - * ID of the user avatar (references images table) - */ - avatar_id?: number; + image?: Image; /** * User email */ diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts index 26d5ddc..42b7919 100644 --- a/modules/frontend/src/api/models/UserTitle.ts +++ b/modules/frontend/src/api/models/UserTitle.ts @@ -2,4 +2,14 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type UserTitle = Record<string, any>; +import type { Title } from './Title'; +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitle = { + user_id: number; + title?: Title; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index bb42012..874971e 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -238,6 +238,7 @@ export class DefaultService { }, errors: { 400: `Request params are not correct`, + 404: `User not found`, 500: `Unknown server error`, }, }); diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx index 98b1295..26f1658 100644 --- a/modules/frontend/src/components/Header/Header.tsx +++ b/modules/frontend/src/components/Header/Header.tsx @@ -12,7 +12,7 @@ export const Header: React.FC<HeaderProps> = ({ username }) => { const toggleMenu = () => setMenuOpen(!menuOpen); return ( - <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50"> + <header className="w-full bg-white shadow-md sticky top-0 left-0 z-50"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="flex justify-between h-16 items-center"> diff --git a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx new file mode 100644 index 0000000..ad7d5df --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardHorizontal({ title }: { title: UserTitle }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={80} /> + )} + <div> + <h3>{title.title?.title_names["en"]}</h3> + <p>{title.title?.release_year} · {title.title?.release_season} · Rating: {title.title?.rating}</p> + <p>Status: {title.title?.title_status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx new file mode 100644 index 0000000..edcf1d5 --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardSquare({ title }: { title: UserTitle }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={140} /> + )} + <div> + <h4>{title.title?.title_names["en"]}</h4> + <h5>{title.status}</h5> + <small>{title.title?.release_year} • {title.title?.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 52c5574..2e39e6b 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -35,9 +35,9 @@ const UserPage: React.FC = () => { <div className={styles.container}> <div className={styles.header}> <div className={styles.avatarWrapper}> - {user.avatar_id ? ( + {user.image?.image_path ? ( <img - src={`/images/${user.avatar_id}.png`} + src={`/images/${user.image.image_path}.png`} alt="User Avatar" className={styles.avatarImg} /> diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx index b5a8336..342f22c 100644 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -6,8 +6,8 @@ import { SearchBar } from "../../components/SearchBar/SearchBar"; import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { ListView } from "../../components/ListView/ListView"; -import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; -import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; +import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; const PAGE_SIZE = 10; @@ -129,7 +129,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { setLoadingMore(false); }; - const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); return ( <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> @@ -139,7 +139,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} {user && ( <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> - <img src={getAvatarUrl(user.avatar_id)} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} @@ -167,7 +167,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { loadingMore={loadingMore} onLoadMore={handleLoadMore} renderItem={(title, layout) => - layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} /> + layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> } /> From 65b76d58c3936c84e9bedba00008046dc090e961 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 03:19:53 +0300 Subject: [PATCH 94/97] fix: now post usertitle dont need title body --- api/_build/openapi.yaml | 24 +++++++++++++++++++++++- api/api.gen.go | 14 +++++++++++++- api/paths/users-id-titles.yaml | 25 ++++++++++++++++++++++++- modules/backend/handlers/users.go | 8 ++++---- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 6b39558..d816a3a 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -326,7 +326,29 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserTitle' + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time responses: '200': description: Title successfully added to user diff --git a/api/api.gen.go b/api/api.gen.go index f3e935c..5c49f12 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -226,11 +226,23 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// AddUserTitleJSONBody defines parameters for AddUserTitle. +type AddUserTitleJSONBody struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` +} + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. -type AddUserTitleJSONRequestBody = UserTitle +type AddUserTitleJSONRequestBody AddUserTitleJSONBody // Getter for additional properties for Title. Returns the specified // element and whether it was found diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 23ea761..80b9916 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -108,7 +108,30 @@ post: content: application/json: schema: - $ref: '../schemas/UserTitle.yaml' + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ../schemas/enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + responses: '200': description: Title successfully added to user diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d800e7a..89b77e0 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -140,9 +140,9 @@ func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, e } func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { - var sqlc_status sqlc.UsertitleStatusT + var sqlc_status sqlc.UsertitleStatusT = sqlc.UsertitleStatusTFinished if s == nil { - return nil, nil + return &sqlc_status, nil } switch *s { @@ -304,7 +304,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU tmp := fmt.Sprint(*t.Title.ReleaseYear) new_cursor.Param = &tmp case "rating": - tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) + tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) // падает new_cursor.Param = &tmp } } @@ -369,7 +369,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque params := sqlc.InsertUserTitleParams{ UserID: request.UserId, - TitleID: request.Body.Title.Id, + TitleID: request.Body.TitleId, Status: *status, Rate: request.Body.Rate, ReviewID: request.Body.ReviewId, From cb9fba6fbc5f2ec5e9b581bdea6cddde9508b071 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 03:46:40 +0300 Subject: [PATCH 95/97] feat: patch usertitle described --- api/_build/openapi.yaml | 105 ++++++++++++------- api/api.gen.go | 162 ++---------------------------- api/paths/users-id-titles.yaml | 76 +++++++++----- modules/backend/handlers/users.go | 4 +- 4 files changed, 133 insertions(+), 214 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index d816a3a..e7482c1 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -328,13 +328,9 @@ paths: schema: type: object required: - - user_id - title_id - status properties: - user_id: - type: integer - format: int64 title_id: type: integer format: int64 @@ -343,12 +339,6 @@ paths: rate: type: integer format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time responses: '200': description: Title successfully added to user @@ -356,32 +346,29 @@ paths: application/json: schema: type: object + required: + - user_id + - title_id + - status properties: - data: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: false '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -394,6 +381,53 @@ paths: description: Conflict — title already assigned to user (if applicable) '500': description: Internal server error + patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + properties: + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error components: parameters: cursor: @@ -629,4 +663,3 @@ components: ctime: type: string format: date-time - additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index 5c49f12..cb5c1ae 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -151,10 +151,9 @@ type UserTitle struct { ReviewId *int64 `json:"review_id,omitempty"` // Status User's title status - Status UserTitleStatus `json:"status"` - Title *Title `json:"title,omitempty"` - UserId int64 `json:"user_id"` - AdditionalProperties map[string]interface{} `json:"-"` + Status UserTitleStatus `json:"status"` + Title *Title `json:"title,omitempty"` + UserId int64 `json:"user_id"` } // UserTitleStatus User's title status @@ -486,145 +485,6 @@ func (a Title) MarshalJSON() ([]byte, error) { return json.Marshal(object) } -// Getter for additional properties for UserTitle. Returns the specified -// element and whether it was found -func (a UserTitle) Get(fieldName string) (value interface{}, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return -} - -// Setter for additional properties for UserTitle -func (a *UserTitle) Set(fieldName string, value interface{}) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]interface{}) - } - a.AdditionalProperties[fieldName] = value -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a *UserTitle) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["ctime"]; found { - err = json.Unmarshal(raw, &a.Ctime) - if err != nil { - return fmt.Errorf("error reading 'ctime': %w", err) - } - delete(object, "ctime") - } - - if raw, found := object["rate"]; found { - err = json.Unmarshal(raw, &a.Rate) - if err != nil { - return fmt.Errorf("error reading 'rate': %w", err) - } - delete(object, "rate") - } - - if raw, found := object["review_id"]; found { - err = json.Unmarshal(raw, &a.ReviewId) - if err != nil { - return fmt.Errorf("error reading 'review_id': %w", err) - } - delete(object, "review_id") - } - - if raw, found := object["status"]; found { - err = json.Unmarshal(raw, &a.Status) - if err != nil { - return fmt.Errorf("error reading 'status': %w", err) - } - delete(object, "status") - } - - if raw, found := object["title"]; found { - err = json.Unmarshal(raw, &a.Title) - if err != nil { - return fmt.Errorf("error reading 'title': %w", err) - } - delete(object, "title") - } - - if raw, found := object["user_id"]; found { - err = json.Unmarshal(raw, &a.UserId) - if err != nil { - return fmt.Errorf("error reading 'user_id': %w", err) - } - delete(object, "user_id") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]interface{}) - for fieldName, fieldBuf := range object { - var fieldVal interface{} - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a UserTitle) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) - - if a.Ctime != nil { - object["ctime"], err = json.Marshal(a.Ctime) - if err != nil { - return nil, fmt.Errorf("error marshaling 'ctime': %w", err) - } - } - - if a.Rate != nil { - object["rate"], err = json.Marshal(a.Rate) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rate': %w", err) - } - } - - if a.ReviewId != nil { - object["review_id"], err = json.Marshal(a.ReviewId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'review_id': %w", err) - } - } - - object["status"], err = json.Marshal(a.Status) - if err != nil { - return nil, fmt.Errorf("error marshaling 'status': %w", err) - } - - if a.Title != nil { - object["title"], err = json.Marshal(a.Title) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title': %w", err) - } - } - - object["user_id"], err = json.Marshal(a.UserId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'user_id': %w", err) - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} - // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -1313,16 +1173,14 @@ type AddUserTitleResponseObject interface { } type AddUserTitle200JSONResponse struct { - Data *struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` - // Status User's title status - Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` - } `json:"data,omitempty"` + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` } func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 80b9916..1580cc1 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -110,13 +110,9 @@ post: schema: type: object required: - - user_id - title_id - status properties: - user_id: - type: integer - format: int64 title_id: type: integer format: int64 @@ -125,12 +121,6 @@ post: rate: type: integer format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time responses: '200': @@ -138,20 +128,8 @@ post: content: application/json: schema: - type: object - properties: - # success: - # type: boolean - # example: true - # error: - # type: string - # nullable: true - # example: null - data: - $ref: '../schemas/UserTitleMini.yaml' - # required: - # - success - # - error + $ref: '../schemas/UserTitleMini.yaml' + '400': description: Invalid request body (missing fields, invalid types, etc.) '401': @@ -162,5 +140,55 @@ post: description: User or Title not found '409': description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + properties: + title_id: + type: integer + format: int64 + status: + $ref: ../schemas/enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 89b77e0..1881f36 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -360,7 +360,7 @@ func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestOb } func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { - + //TODO: add review if exists status, err := UserTitleStatus2Sqlc1(&request.Body.Status) if err != nil { log.Errorf("%v", err) @@ -404,5 +404,5 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque UserId: user_title.UserID, } - return oapi.AddUserTitle200JSONResponse{Data: &oapi_usertitle}, nil + return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil } From 68294dd13c3bd02e57929260ecab044d3f63fd38 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:11:55 +0300 Subject: [PATCH 96/97] fix: oapi shitty generation --- api/_build/openapi.yaml | 402 +++++++++--------- api/paths/titles-id.yaml | 1 + api/paths/users-id-titles.yaml | 7 +- api/paths/users-id.yaml | 1 + api/schemas/Title.yaml | 1 - api/schemas/UserTitleMini.yaml | 1 - modules/frontend/src/api/index.ts | 2 + modules/frontend/src/api/models/Image.ts | 6 +- .../frontend/src/api/models/StorageType.ts | 8 + modules/frontend/src/api/models/Title.ts | 28 +- .../frontend/src/api/models/UserTitleMini.ts | 14 + .../src/api/services/DefaultService.ts | 51 ++- .../src/pages/TitlePage/TitlePage.module.css | 0 .../src/pages/TitlePage/TitlePage.tsx | 64 --- .../frontend/src/pages/UserPage/UserPage.tsx | 2 +- .../src/pages/UsersIdPage/UsersIdPage.tsx | 2 +- 16 files changed, 302 insertions(+), 288 deletions(-) create mode 100644 modules/frontend/src/api/models/StorageType.ts create mode 100644 modules/frontend/src/api/models/UserTitleMini.ts delete mode 100644 modules/frontend/src/pages/TitlePage/TitlePage.module.css diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e7482c1..720b686 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -11,52 +11,52 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: offset + - name: offset + in: query schema: type: integer format: int32 default: 0 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -69,10 +69,10 @@ paths: type: object properties: data: + description: List of titles type: array items: $ref: '#/components/schemas/Title' - description: List of titles cursor: $ref: '#/components/schemas/CursorObj' required: @@ -86,16 +86,17 @@ paths: description: Unknown server error '/titles/{title_id}': get: + operationId: getTitle summary: Get title description parameters: - - in: path - name: title_id + - name: title_id + in: path required: true schema: type: integer format: int64 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -116,15 +117,16 @@ paths: description: Unknown server error '/users/{user_id}': get: + operationId: getUsersId summary: Get user info parameters: - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -142,59 +144,59 @@ paths: '500': description: Unknown server error patch: + operationId: updateUser summary: Partially update a user account description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. - operationId: updateUser parameters: - name: user_id in: path + description: User ID (primary key) required: true schema: type: integer format: int64 - description: User ID (primary key) example: 123 requestBody: required: true content: application/json: schema: + description: Only provided fields are updated. Omitted fields remain unchanged. type: object properties: avatar_id: + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar type: integer format: int64 - nullable: true - description: ID of the user avatar (references `images.id`); set to `null` to remove avatar example: 42 + nullable: true mail: + description: User email (must be unique and valid) type: string format: email - pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' - description: User email (must be unique and valid) example: john.doe.updated@example.com + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' nickname: - type: string - pattern: '^[a-zA-Z0-9_-]{3,16}$' description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + type: string + example: john_doe_43 maxLength: 16 minLength: 3 - example: john_doe_43 + pattern: '^[a-zA-Z0-9_-]{3,16}$' disp_name: - type: string description: Display name - maxLength: 32 - example: John Smith - user_desc: type: string + example: John Smith + maxLength: 32 + user_desc: description: User description / bio - maxLength: 512 + type: string example: Just a curious developer. + maxLength: 512 additionalProperties: false - description: Only provided fields are updated. Omitted fields remain unchanged. responses: '200': description: User updated successfully. Returns updated user representation (excluding sensitive fields). @@ -222,64 +224,64 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: watch_status + style: form + - name: watch_status + in: query schema: type: array items: $ref: '#/components/schemas/UserTitleStatus' - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: my_rate + - name: my_rate + in: query schema: type: integer format: int32 - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -309,17 +311,17 @@ paths: '500': description: Unknown server error post: + operationId: addUserTitle summary: Add a title to a user description: 'User adding title to list af watched, status required' - operationId: addUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -327,9 +329,6 @@ paths: application/json: schema: type: object - required: - - title_id - - status properties: title_id: type: integer @@ -339,36 +338,16 @@ paths: rate: type: integer format: int32 + required: + - title_id + - status responses: '200': description: Title successfully added to user content: application/json: schema: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -382,17 +361,17 @@ paths: '500': description: Internal server error patch: + operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched - operationId: updateUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -400,8 +379,6 @@ paths: application/json: schema: type: object - required: - - title_id properties: title_id: type: integer @@ -411,13 +388,15 @@ paths: rate: type: integer format: int32 + required: + - title_id responses: '200': description: Title successfully updated content: application/json: schema: - $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -443,25 +422,36 @@ components: schema: $ref: '#/components/schemas/TitleSort' schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string TitleSort: - type: string description: Title sort order + type: string default: id enum: - id - year - rating - views + TitleStatus: + description: Title status + type: string + enum: + - finished + - ongoing + - planned + ReleaseSeason: + description: Title release season + type: string + enum: + - winter + - spring + - summer + - fall + StorageType: + description: Image storage type + type: string + enum: + - s3 + - local Image: type: object properties: @@ -469,65 +459,11 @@ components: type: integer format: int64 storage_type: - type: string - description: Image storage type - enum: - - s3 - - local + $ref: '#/components/schemas/StorageType' image_path: type: string - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - Review: - type: object - additionalProperties: true - Tag: - type: object - description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' - additionalProperties: - type: string - example: - en: Shojo - ru: Сёдзё - ja: 少女 - Tags: - type: array - description: Array of localized tags - items: - $ref: '#/components/schemas/Tag' - example: - - en: Shojo - ru: Сёдзё - ja: 少女 - - en: Shounen - ru: Сёнен - ja: 少年 Studio: type: object - required: - - id - - name properties: id: type: integer @@ -538,30 +474,41 @@ components: $ref: '#/components/schemas/Image' description: type: string - Title: - type: object required: - id - - title_names - - tags + - name + Tag: + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + type: object + example: + en: Shojo + ru: Сёдзё + ja: 少女 + additionalProperties: + type: string + Tags: + description: Array of localized tags + type: array + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Title: + type: object properties: id: + description: Unique title ID (primary key) type: integer format: int64 - description: Unique title ID (primary key) example: 1 title_names: - type: object description: 'Localized titles. Key = language (ISO 639-1), value = list of names' - additionalProperties: - type: array - items: - type: string - example: Attack on Titan - minItems: 1 - example: - - Attack on Titan - - AoT + type: object example: en: - Attack on Titan @@ -571,6 +518,15 @@ components: - Титаны ja: - 進撃の巨人 + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT studio: $ref: '#/components/schemas/Studio' tags: @@ -601,51 +557,68 @@ components: additionalProperties: type: number format: double - additionalProperties: true - User: + required: + - id + - title_names + - tags + CursorObj: type: object properties: id: type: integer format: int64 + param: + type: string + required: + - id + User: + type: object + properties: + id: description: Unique user ID (primary key) + type: integer + format: int64 example: 1 image: $ref: '#/components/schemas/Image' mail: + description: User email type: string format: email - description: User email example: john.doe@example.com nickname: - type: string description: Username (alphanumeric + _ or -) - maxLength: 16 + type: string example: john_doe_42 + maxLength: 16 disp_name: - type: string description: Display name - maxLength: 32 - example: John Doe - user_desc: type: string + example: John Doe + maxLength: 32 + user_desc: description: User description - maxLength: 512 + type: string example: Just a regular user. + maxLength: 512 creation_date: + description: Timestamp when the user was created type: string format: date-time - description: Timestamp when the user was created example: '2025-10-10T23:45:47.908073Z' required: - user_id - nickname + UserTitleStatus: + description: User's title status + type: string + enum: + - finished + - planned + - dropped + - in-progress UserTitle: type: object - required: - - user_id - - title_id - - status properties: user_id: type: integer @@ -663,3 +636,34 @@ components: ctime: type: string format: date-time + required: + - user_id + - title_id + - status + UserTitleMini: + type: object + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + required: + - user_id + - title_id + - status + Review: + type: object + additionalProperties: true diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml index 01fa504..235743f 100644 --- a/api/paths/titles-id.yaml +++ b/api/paths/titles-id.yaml @@ -1,5 +1,6 @@ get: summary: Get title description + operationId: getTitle parameters: - in: path name: title_id diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 1580cc1..4f11ab6 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -117,11 +117,10 @@ post: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 - responses: '200': description: Title successfully added to user @@ -129,7 +128,6 @@ post: application/json: schema: $ref: '../schemas/UserTitleMini.yaml' - '400': description: Invalid request body (missing fields, invalid types, etc.) '401': @@ -169,7 +167,7 @@ patch: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 @@ -181,7 +179,6 @@ patch: application/json: schema: $ref: '../schemas/UserTitleMini.yaml' - '400': description: Invalid request body (missing fields, invalid types, etc.) '401': diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 06f4a19..fe62e46 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,5 +1,6 @@ get: summary: Get user info + operationId: getUsersId parameters: - in: path name: user_id diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml index 7497d1f..877ee24 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -60,4 +60,3 @@ properties: additionalProperties: type: number format: double -additionalProperties: true diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml index 9e45e95..e1a5a74 100644 --- a/api/schemas/UserTitleMini.yaml +++ b/api/schemas/UserTitleMini.yaml @@ -21,4 +21,3 @@ properties: ctime: type: string format: date-time -additionalProperties: false diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 80ae491..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -12,6 +12,7 @@ export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; export type { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; +export type { StorageType } from './models/StorageType'; export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; export type { Tags } from './models/Tags'; @@ -21,6 +22,7 @@ export type { TitleSort } from './models/TitleSort'; export type { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; +export type { UserTitleMini } from './models/UserTitleMini'; export type { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts index a94de74..887bf2f 100644 --- a/modules/frontend/src/api/models/Image.ts +++ b/modules/frontend/src/api/models/Image.ts @@ -2,12 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { StorageType } from './StorageType'; export type Image = { id?: number; - /** - * Image storage type - */ - storage_type?: 's3' | 'local'; + storage_type?: StorageType; image_path?: string; }; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts new file mode 100644 index 0000000..f6d086b --- /dev/null +++ b/modules/frontend/src/api/models/StorageType.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts index 4da7aa3..9ffdeb6 100644 --- a/modules/frontend/src/api/models/Title.ts +++ b/modules/frontend/src/api/models/Title.ts @@ -2,4 +2,30 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Title = Record<string, any>; +import type { Image } from './Image'; +import type { ReleaseSeason } from './ReleaseSeason'; +import type { Studio } from './Studio'; +import type { Tags } from './Tags'; +import type { TitleStatus } from './TitleStatus'; +export type Title = { + /** + * Unique title ID (primary key) + */ + id: number; + /** + * Localized titles. Key = language (ISO 639-1), value = list of names + */ + title_names: Record<string, Array<string>>; + studio?: Studio; + tags: Tags; + poster?: Image; + title_status?: TitleStatus; + rating?: number; + rating_count?: number; + release_year?: number; + release_season?: ReleaseSeason; + episodes_aired?: number; + episodes_all?: number; + episodes_len?: Record<string, number>; +}; + diff --git a/modules/frontend/src/api/models/UserTitleMini.ts b/modules/frontend/src/api/models/UserTitleMini.ts new file mode 100644 index 0000000..2b223ce --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleMini.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 874971e..5070fae 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -9,6 +9,7 @@ import type { TitleSort } from '../models/TitleSort'; import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleMini } from '../models/UserTitleMini'; import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; @@ -78,7 +79,7 @@ export class DefaultService { * @returns Title Title description * @throws ApiError */ - public static getTitles1( + public static getTitle( titleId: number, fields: string = 'all', ): CancelablePromise<Title> { @@ -105,7 +106,7 @@ export class DefaultService { * @returns User User info * @throws ApiError */ - public static getUsers( + public static getUsersId( userId: string, fields: string = 'all', ): CancelablePromise<User> { @@ -248,22 +249,17 @@ export class DefaultService { * User adding title to list af watched, status required * @param userId ID of the user to assign the title to * @param requestBody - * @returns any Title successfully added to user + * @returns UserTitleMini Title successfully added to user * @throws ApiError */ public static addUserTitle( userId: number, - requestBody: UserTitle, - ): CancelablePromise<{ - data?: { - user_id: number; + requestBody: { title_id: number; status: UserTitleStatus; rate?: number; - review_id?: number; - ctime?: string; - }; - }> { + }, + ): CancelablePromise<UserTitleMini> { return __request(OpenAPI, { method: 'POST', url: '/users/{user_id}/titles', @@ -282,4 +278,37 @@ export class DefaultService { }, }); } + /** + * Update a usertitle + * User updating title list of watched + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns UserTitleMini Title successfully updated + * @throws ApiError + */ + public static updateUserTitle( + userId: number, + requestBody: { + title_id: number; + status?: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to update title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css deleted file mode 100644 index e69de29..0000000 diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 7fe9de7..e69de29 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,64 +0,0 @@ -// import React, { useEffect, useState } from "react"; -// import { useParams } from "react-router-dom"; -// import { DefaultService } from "../../api/services/DefaultService"; -// import type { User } from "../../api/models/User"; -// import styles from "./UserPage.module.css"; - -// const UserPage: React.FC = () => { -// const { id } = useParams<{ id: string }>(); -// const [user, setUser] = useState<User | null>(null); -// const [loading, setLoading] = useState(true); -// const [error, setError] = useState<string | null>(null); - -// useEffect(() => { -// if (!id) return; - -// const getTitleInfo = async () => { -// try { -// const userInfo = await DefaultService.getTitle(id, "all"); -// setUser(userInfo); -// } catch (err) { -// console.error(err); -// setError("Failed to fetch user info."); -// } finally { -// setLoading(false); -// } -// }; -// getTitleInfo(); -// }, [id]); - -// if (loading) return <div className={styles.loader}>Loading...</div>; -// if (error) return <div className={styles.error}>{error}</div>; -// if (!user) return <div className={styles.error}>User not found.</div>; - -// return ( -// <div className={styles.container}> -// <div className={styles.card}> -// <div className={styles.avatar}> -// {user.avatar_id ? ( -// <img -// src={`/images/${user.avatar_id}.png`} -// alt="User Avatar" -// className={styles.avatarImg} -// /> -// ) : ( -// <div className={styles.avatarPlaceholder}> -// {user.disp_name?.[0] || "U"} -// </div> -// )} -// </div> - -// <div className={styles.info}> -// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> -// <p className={styles.nickname}>@{user.nickname}</p> -// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} -// <p className={styles.created}> -// Joined: {new Date(user.creation_date).toLocaleDateString()} -// </p> -// </div> -// </div> -// </div> -// ); -// }; - -// export default UserPage; diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 2e39e6b..eafdf6b 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -15,7 +15,7 @@ const UserPage: React.FC = () => { const getUserInfo = async () => { try { - const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id + const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id setUser(userInfo); } catch (err) { console.error(err); diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx index 342f22c..729da20 100644 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -41,7 +41,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { if (!id) return; setLoadingUser(true); try { - const result = await DefaultService.getUsers(id, "all"); + const result = await DefaultService.getUsersId(id, "all"); setUser(result); setErrorUser(null); } catch (err: any) { From 4c643d80bb35cff875e4e5d2fad9eec2fc4e0bcc Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:29:36 +0300 Subject: [PATCH 97/97] feat: added title page --- modules/frontend/src/App.tsx | 3 + modules/frontend/src/api/core/OpenAPI.ts | 2 +- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../src/pages/TitlePage/TitlePage.tsx | 140 ++++++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 3ecfa2d..e2c909f 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; @@ -24,7 +25,9 @@ const App: React.FC = () => { /> <Route path="/users/:id" element={<UsersIdPage />} /> + <Route path="/titles" element={<TitlesPage />} /> + <Route path="/titles/:id" element={<TitlePage />} /> </Routes> </Router> ); diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 185e5c3..6ce873e 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', + BASE: 'http://10.1.0.65:8081/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 2d0edf8..79aa305 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/auth', + BASE: 'http://10.1.0.65:8081/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index e69de29..5ea0e3d 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title, UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" }, + { status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" }, + { status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" }, + { status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" }, +]; + +export default function TitlePage() { + const params = useParams(); + const titleId = Number(params.id); + + const [title, setTitle] = useState<Title | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null); + const [updatingStatus, setUpdatingStatus] = useState(false); + + useEffect(() => { + const fetchTitle = async () => { + setLoading(true); + try { + const data = await DefaultService.getTitle(titleId, "all"); + setTitle(data); + setError(null); + } catch (err: any) { + console.error(err); + setError(err?.message || "Failed to fetch title"); + } finally { + setLoading(false); + } + }; + fetchTitle(); + }, [titleId]); + + const handleStatusClick = async (status: UserTitleStatus) => { + if (updatingStatus || userStatus === status) return; + + const userId = Number(localStorage.getItem("userId")); + if (!userId) { + alert("You must be logged in to set status."); + return; + } + + setUpdatingStatus(true); + try { + await DefaultService.addUserTitle(userId, { + title_id: titleId, + status, + }); + setUserStatus(status); + } catch (err: any) { + console.error(err); + alert(err?.message || "Failed to set status"); + } finally { + setUpdatingStatus(false); + } + }; + + const getTagsString = () => + title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); + + if (loading) return <div className="mt-20 font-medium text-black">Loading title...</div>; + if (error) return <div className="mt-20 text-red-600 font-medium">{error}</div>; + if (!title) return null; + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> + <div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> + {/* Постер */} + <div className="flex flex-col items-center"> + <img + src={title.poster?.image_path || "/default-poster.png"} + alt={title.title_names?.en?.[0] || "Title poster"} + className="w-48 h-72 object-cover rounded-lg mb-4" + /> + + {/* Статус кнопки с иконками */} + <div className="flex gap-2 mt-2 flex-wrap justify-center"> + {STATUS_BUTTONS.map(btn => ( + <button + key={btn.status} + onClick={() => handleStatusClick(btn.status)} + disabled={updatingStatus} + className={`p-2 rounded-lg transition flex items-center justify-center ${ + userStatus === btn.status + ? "bg-blue-600 text-white" + : "bg-gray-200 text-gray-700 hover:bg-gray-300" + }`} + title={btn.label} + > + {btn.icon} + </button> + ))} + </div> + </div> + + {/* Информация о тайтле */} + <div className="flex-1 flex flex-col"> + <h1 className="text-3xl font-bold mb-2"> + {title.title_names?.en?.[0] || "Untitled"} + </h1> + {title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>} + {title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>} + {title.rating !== undefined && ( + <p className="text-gray-700 mb-1"> + Rating: {title.rating} ({title.rating_count} votes) + </p> + )} + {title.release_year && ( + <p className="text-gray-700 mb-1"> + Released: {title.release_year} {title.release_season || ""} + </p> + )} + {title.episodes_aired !== undefined && ( + <p className="text-gray-700 mb-1"> + Episodes: {title.episodes_aired}/{title.episodes_all} + </p> + )} + {title.tags && title.tags.length > 0 && ( + <p className="text-gray-700 mb-1"> + Tags: {getTagsString()} + </p> + )} + </div> + </div> + </div> + ); +}