diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 722b7af..c059166 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -158,12 +158,7 @@ paths: - in: query name: status schema: - type: array - items: - $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form - explode: false + $ref: '#/components/schemas/TitleStatus' - in: query name: watch_status schema: @@ -429,8 +424,9 @@ components: user_id: type: integer format: int64 - title: - $ref: '#/components/schemas/Title' + title_id: + type: integer + format: int64 status: $ref: '#/components/schemas/UserTitleStatus' rate: diff --git a/api/api.gen.go b/api/api.gen.go index 54c8fc1..e56f6b8 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"` - Title *Title `json:"title,omitempty"` + TitleId int64 `json:"title_id"` UserId int64 `json:"user_id"` AdditionalProperties map[string]interface{} `json:"-"` } @@ -183,11 +183,9 @@ 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 List of title statuses to filter - 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 *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"` @@ -495,12 +493,12 @@ func (a *UserTitle) UnmarshalJSON(b []byte) error { delete(object, "status") } - if raw, found := object["title"]; found { - err = json.Unmarshal(raw, &a.Title) + if raw, found := object["title_id"]; found { + err = json.Unmarshal(raw, &a.TitleId) if err != nil { - return fmt.Errorf("error reading 'title': %w", err) + return fmt.Errorf("error reading 'title_id': %w", err) } - delete(object, "title") + delete(object, "title_id") } if raw, found := object["user_id"]; found { @@ -556,11 +554,9 @@ func (a UserTitle) MarshalJSON() ([]byte, error) { 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["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) @@ -813,7 +809,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { // ------------- Optional query parameter "status" ------------- - err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.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 diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0788319..0cde5af 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -14,12 +14,7 @@ get: - in: query name: status schema: - type: array - items: - $ref: '../schemas/enums/TitleStatus.yaml' - description: List of title statuses to filter - style: form - explode: false + $ref: '../schemas/enums/TitleStatus.yaml' - in: query name: watch_status schema: diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index 3beaec6..658e350 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -7,8 +7,9 @@ properties: user_id: type: integer format: int64 - title: - $ref: ./Title.yaml + title_id: + type: integer + format: int64 status: $ref: ./enums/UserTitleStatus.yaml rate: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 6618d49..3d61b91 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -1,10 +1,6 @@ package handlers import ( - "context" - "encoding/json" - "fmt" - oapi "nyanimedb/api" sqlc "nyanimedb/sql" "strconv" ) @@ -17,75 +13,70 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } -func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { +// type Cursor interface { +// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) - 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) - } +// Values() map[string]interface{} +// // for logs only +// Type() string +// } - 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) - } +// type CursorByID struct { +// ID int64 +// } - 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) 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 +// } - var oapi_studio oapi.Studio +// func (c CursorByID) Values() map[string]interface{} { +// return map[string]interface{}{ +// "cursor_id": c.ID, +// "cursor_year": nil, +// "cursor_rating": nil, +// } +// } - 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 (c CursorByID) Type() string { return "id" } - var oapi_image oapi.Image +// 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) +// } +// } - if title.PosterID != nil { - oapi_image.Id = title.PosterID - oapi_image.ImagePath = title.TitleImagePath - oapi_image.StorageType = &title.TitleStorageType - } +// decodes a base64-encoded JSON string into a CursorObj +// Returns the parsed CursorObj and an error +// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) { - var release_season oapi.ReleaseSeason - if title.ReleaseSeason != nil { - release_season = oapi.ReleaseSeason(*title.ReleaseSeason) - } +// // Decode base64 +// decoded, err := base64.StdEncoding.DecodeString(encoded) +// if 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: - } +// // Parse JSON +// var cursor oapi.CursorObj +// if err := json.Unmarshal(decoded, &cursor); err != nil { +// return nil, fmt.Errorf("parseCursor: %v", err) +// } - return oapi_title, nil -} +// return &cursor, 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 78323f6..71547c2 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) { - var sqlc_status SqlcStatus +func TitleStatus2Sqlc(s []oapi.TitleStatus) (*SqlcStatus, error) { if s == nil { - return &sqlc_status, nil + return nil, nil } - for _, t := range *s { + var sqlc_status SqlcStatus + for _, t := range s { switch t { case oapi.TitleStatusFinished: sqlc_status.finished = "finished" @@ -158,6 +158,78 @@ 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 @@ -204,12 +276,11 @@ 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) @@ -267,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 := fmt.Sprint(*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/handlers/users.go b/modules/backend/handlers/users.go index 1ea2c1a..0fa903f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -2,15 +2,12 @@ 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 { @@ -53,120 +50,33 @@ 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) { - oapi_usertitles := 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) - 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 - } - - 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 { - 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.GetUsersUserIdTitles400Response{}, nil - } - } - } - // 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, - } + 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 diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ee2a84a..16f8120 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -78,10 +78,7 @@ SELECT t.*, 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, + 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, @@ -89,13 +86,13 @@ 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) LEFT JOIN images as si ON (s.illust_id = si.id) -WHERE t.id = sqlc.arg('title_id')::bigint +WHERE id = sqlc.arg('title_id')::bigint GROUP BY t.id, i.id, s.id, si.id; @@ -104,10 +101,7 @@ SELECT t.*, 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, + 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, @@ -115,7 +109,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) @@ -187,13 +181,7 @@ WHERE END ) - AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::text[]) = 0 - OR - -- Иначе: статус есть в списке - t.title_status = ANY(sqlc.arg('title_statuses')::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) @@ -222,125 +210,37 @@ ORDER BY LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- name: SearchUserTitles :many -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(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 +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 ( - 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('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) - -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 + 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 diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index 40bb520..6a06afb 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,14 +8,10 @@ "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", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4", - "tailwindcss": "^4.1.17" + "react-router-dom": "^7.9.4" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -31,9 +27,6 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" - }, - "engines": { - "node": "20.x" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -344,6 +337,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -360,6 +354,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -376,6 +371,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -392,6 +388,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -408,6 +405,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -424,6 +422,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -440,6 +439,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -456,6 +456,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -472,6 +473,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -488,6 +490,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -504,6 +507,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -520,6 +524,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -536,6 +541,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -552,6 +558,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -568,6 +575,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -584,6 +592,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,6 +609,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -616,6 +626,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -632,6 +643,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -648,6 +660,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -664,6 +677,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -680,6 +694,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -696,6 +711,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -712,6 +728,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,6 +745,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -744,6 +762,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -910,88 +929,6 @@ "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", - "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", @@ -1048,6 +985,7 @@ "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", @@ -1058,6 +996,7 @@ "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", @@ -1068,6 +1007,7 @@ "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" @@ -1077,12 +1017,14 @@ "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", @@ -1134,103 +1076,6 @@ "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", @@ -1245,6 +1090,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1258,6 +1104,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1271,6 +1118,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1284,6 +1132,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1297,6 +1146,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1310,6 +1160,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1323,6 +1174,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1336,6 +1188,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1349,6 +1202,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1362,6 +1216,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1375,6 +1230,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1388,6 +1244,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1401,6 +1258,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1414,6 +1272,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1427,6 +1286,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1440,6 +1300,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1453,6 +1314,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1466,6 +1328,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1479,6 +1342,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1492,6 +1356,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1505,6 +1370,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1518,305 +1384,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "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", - "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/@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", @@ -1866,6 +1440,7 @@ "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": { @@ -1879,7 +1454,7 @@ "version": "24.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2431,15 +2006,6 @@ "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", @@ -2561,15 +2127,6 @@ "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", @@ -2591,19 +2148,6 @@ "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", @@ -2653,6 +2197,7 @@ "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": { @@ -3072,6 +2617,7 @@ "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, @@ -3180,6 +2726,7 @@ "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": { @@ -3337,15 +2884,6 @@ "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", @@ -3450,255 +2988,6 @@ "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", @@ -3732,15 +3021,6 @@ "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", @@ -3829,6 +3109,7 @@ "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", @@ -3968,6 +3249,7 @@ "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": { @@ -3987,6 +3269,7 @@ "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", @@ -4154,6 +3437,7 @@ "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" @@ -4274,6 +3558,7 @@ "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" @@ -4305,35 +3590,11 @@ "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", - "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", @@ -4350,6 +3611,7 @@ "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" @@ -4367,6 +3629,7 @@ "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": { @@ -4402,12 +3665,6 @@ "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", @@ -4478,7 +3735,7 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -4532,19 +3789,11 @@ "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", "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -4620,6 +3869,7 @@ "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" @@ -4637,6 +3887,7 @@ "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 e0b65ba..f15ffd5 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,14 +10,10 @@ "preview": "vite preview" }, "dependencies": { - "@headlessui/react": "^2.2.9", - "@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", - "tailwindcss": "^4.1.17" + "react-router-dom": "^7.9.4" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/modules/frontend/src/App.css b/modules/frontend/src/App.css index e69de29..b9d355d 100644 --- a/modules/frontend/src/App.css +++ b/modules/frontend/src/App.css @@ -0,0 +1,42 @@ +#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 909ad6c..f67c37e 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,13 +2,10 @@ 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 ( -
} /> } /> diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 80ae491..f0d09ee 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -8,19 +8,16 @@ 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 type { ReleaseSeason } from './models/ReleaseSeason'; +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 type { title_sort } from './models/title_sort'; -export type { TitleSort } from './models/TitleSort'; -export type { TitleStatus } from './models/TitleStatus'; +export { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; -export type { UserTitleStatus } from './models/UserTitleStatus'; +export { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts index ad9f930..182b980 100644 --- a/modules/frontend/src/api/models/ReleaseSeason.ts +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -5,4 +5,9 @@ /** * Title release season */ -export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; +export enum ReleaseSeason { + WINTER = 'winter', + SPRING = 'spring', + SUMMER = 'summer', + FALL = 'fall', +} diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts index 72e0261..811ece8 100644 --- a/modules/frontend/src/api/models/TitleStatus.ts +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -5,4 +5,8 @@ /** * Title status */ -export type TitleStatus = 'finished' | 'ongoing' | 'planned'; +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 cd76dbe..541028e 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; + avatar_id?: number | null; /** * User email */ diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts index 0a29626..20651fe 100644 --- a/modules/frontend/src/api/models/UserTitleStatus.ts +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -5,4 +5,9 @@ /** * User's title status */ -export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; +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 index 52321b8..b0ae54d 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -2,23 +2,17 @@ /* 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 @@ -27,13 +21,10 @@ export class DefaultService { * @param limit * @param offset * @param fields - * @returns any List of titles with cursor + * @returns Title List of titles * @throws ApiError */ public static getTitles( - cursor?: string, - sort?: TitleSort, - sortForward: boolean = true, word?: string, status?: TitleStatus, rating?: number, @@ -42,20 +33,11 @@ export class DefaultService { limit: number = 10, offset?: number, fields: string = 'all', - ): CancelablePromise<{ - /** - * List of titles - */ - data: Array; - cursor: CursorObj; - }> { + ): CancelablePromise<Array<Title>> { return __request(OpenAPI, { method: 'GET', url: '/titles', query: { - 'cursor': cursor, - 'sort': sort, - 'sort_forward': sortForward, 'word': word, 'status': status, 'rating': rating, @@ -129,13 +111,9 @@ export class DefaultService { * Get user titles * @param userId * @param cursor - * @param word - * @param status - * @param watchStatus - * @param rating - * @param releaseYear - * @param releaseSeason + * @param query * @param limit + * @param offset * @param fields * @returns UserTitle List of user titles * @throws ApiError @@ -143,30 +121,22 @@ export class DefaultService { public static getUsersTitles( userId: string, cursor?: string, - word?: string, - status?: TitleStatus, - watchStatus?: UserTitleStatus, - rating?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, + query?: string, 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, - 'word': word, - 'status': status, - 'watch_status': watchStatus, - 'rating': rating, - 'release_year': releaseYear, - 'release_season': releaseSeason, + 'query': query, '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 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/CursorObj.ts b/modules/frontend/src/api_/models/Review.ts similarity index 66% rename from modules/frontend/src/api/models/CursorObj.ts rename to modules/frontend/src/api_/models/Review.ts index f54abb1..9b453b7 100644 --- a/modules/frontend/src/api/models/CursorObj.ts +++ b/modules/frontend/src/api_/models/Review.ts @@ -2,8 +2,4 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type CursorObj = { - id: number; - param?: string; -}; - +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/TitleSort.ts b/modules/frontend/src/api_/models/Tags.ts similarity index 60% rename from modules/frontend/src/api/models/TitleSort.ts rename to modules/frontend/src/api_/models/Tags.ts index 1c9385e..748f066 100644 --- a/modules/frontend/src/api/models/TitleSort.ts +++ b/modules/frontend/src/api_/models/Tags.ts @@ -2,7 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { Tag } from './Tag'; /** - * Title sort order + * Array of localized tags */ -export type TitleSort = 'id' | 'year' | 'rating' | 'views'; +export type Tags = Array<Tag>; diff --git a/modules/frontend/src/api/models/title_sort.ts b/modules/frontend/src/api_/models/Title.ts similarity index 61% rename from modules/frontend/src/api/models/title_sort.ts rename to modules/frontend/src/api_/models/Title.ts index 69b01a7..4da7aa3 100644 --- a/modules/frontend/src/api/models/title_sort.ts +++ b/modules/frontend/src/api_/models/Title.ts @@ -2,5 +2,4 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { TitleSort } from './TitleSort'; -export type title_sort = TitleSort; +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/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx deleted file mode 100644 index 98b1295..0000000 --- a/modules/frontend/src/components/Header/Header.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 679feea..0000000 --- a/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 ea6d683..77fea97 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,48 +1,52 @@ import React from "react"; -export type ListViewProps<T> = { - items: T[]; - layout: "square" | "horizontal"; - renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; - onLoadMore: () => void; - hasMore: boolean; - loadingMore: boolean; -}; +interface ListViewProps<TItem> { + hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>; + renderHorizontal: (item: TItem) => React.ReactNode; + renderSquare: (item: TItem) => React.ReactNode; +} -export function ListView<T>({ - items, - layout, - renderItem, - onLoadMore, - hasMore, - loadingMore -}: ListViewProps<T>) { +export function ListView<TItem>({ + hook, + renderHorizontal, + renderSquare +}: ListViewProps<TItem>) { + const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook; return ( - <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" - }`} - > - {items.map(item => renderItem(item, layout))} + <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> - {/* Load More */} {hasMore && ( - <div className="mt-8"> - <button - 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> + <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/SearchBar/SearchBar.tsx b/modules/frontend/src/components/SearchBar/SearchBar.tsx deleted file mode 100644 index 87aee66..0000000 --- a/modules/frontend/src/components/SearchBar/SearchBar.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index ddafd34..0000000 --- a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { 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/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index cde6037..c3a8159 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.poster?.image_path && ( - <img src={title.poster.image_path} width={80} /> + {title.posterUrl && ( + <img src={title.posterUrl} width={80} /> )} <div> - <h3>{title.title_names["en"]}</h3> - <p>{title.release_year} · {title.release_season} · Rating: {title.rating}</p> - <p>Status: {title.title_status}</p> + <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 index e21c258..0fc0339 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.poster?.image_path && ( - <img src={title.poster.image_path} width={140} /> + {title.posterUrl && ( + <img src={title.posterUrl} width={140} /> )} <div> - <h4>{title.title_names["en"]}</h4> - <small>{title.release_year} • {title.rating}</small> + <h4>{title.name}</h4> + <small>{title.year} • {title.rating}</small> </div> </div> ); diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index 7b32a9b..08a3ac9 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -1,9 +1,68 @@ -@import "tailwindcss"; +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; -html, body, #root { + 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 { margin: 0; - padding: 0; - width: 100%; - height: 100%; - @apply text-black bg-white; -} \ No newline at end of file + 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; + } +} diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css index f1d8c73..9cc728b 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css @@ -1 +1,59 @@ -@import "tailwindcss"; +.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 index 0fec3c8..438d828 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,154 +1,114 @@ -import { useEffect, useState } from "react"; -import { ListView } from "../../components/ListView/ListView"; -import { SearchBar } from "../../components/SearchBar/SearchBar"; -import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import React, { useEffect, useState } from "react"; import { DefaultService } from "../../api/services/DefaultService"; -import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; -import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { CursorObj, Title, TitleSort } from "../../api"; -import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import type { Title } from "../../api/models/Title"; +import styles from "./TitlesPage.module.css"; -const PAGE_SIZE = 10; +const LIMIT = 20; -export default function TitlesPage() { +const TitlesPage: React.FC = () => { const [titles, setTitles] = useState<Title[]>([]); - const [nextPage, setNextPage] = useState<Title[]>([]); - const [cursor, setCursor] = useState<CursorObj | null>(null); const [search, setSearch] = useState(""); + const [offset, setOffset] = useState(0); + 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 fetchPage = async (cursorObj: CursorObj | null) => { - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; + 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( - cursorStr, - sort, - sortForward, - search.trim() || undefined, - undefined, - undefined, - undefined, - undefined, - PAGE_SIZE, - undefined, + search || undefined, + undefined, // status + undefined, // rating + undefined, // release_year + undefined, // release_season + LIMIT, + reset ? 0 : offset, "all" ); - if ((result === undefined) || !result.data?.length) { - return { items: [], nextCursor: null }; + if (reset) { + setTitles(result); + } else { + setTitles(prev => [...prev, ...result]); } - return { - items: result.data ?? [], - nextCursor: result.cursor ?? null - }; - } catch (err: any) { - if (err.status === 204) { - return { items: [], nextCursor: null }; + + if (result.length > 0) { + setOffset(prev => prev + LIMIT); } - throw err; + + } catch (err) { + console.error(err); + setError("Failed to fetch titles."); + } finally { + setLoading(false); + setLoadingMore(false); } }; - // Инициализация: загружаем сразу две страницы useEffect(() => { - const initLoad = async () => { - setLoading(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); - 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); -}; - + fetchTitles(true); + }, [search]); + if (loading) return <div className={styles.loader}>Loading...</div>; + if (error) return <div className={styles.error}>{error}</div>; return ( - <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + <div className={styles.container}> + <div className={styles.header}> + <h1>Titles</h1> - <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} + <input + className={styles.searchInput} + placeholder="Search titles..." + value={search} + onChange={e => setSearch(e.target.value)} /> </div> - {loading && <div className="mt-20 font-medium text-black">Loading...</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> + )} - {!loading && titles.length === 0 && ( - <div className="mt-20 font-medium text-black">No titles found.</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 && ( - <> - <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> - )} - </> + <button + className={styles.loadMore} + onClick={() => fetchTitles(false)} + disabled={loadingMore} + > + {loadingMore ? "Loading..." : "Load More"} + </button> )} </div> ); -} +}; + +export default TitlesPage; diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 554d630..4cfbdd0 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -1,15 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [ - react(), - tailwindcss() - ], + plugins: [react()], server: { - host: '0.0.0.0', + host: '127.0.0.1', port: 8083, }, }) diff --git a/sql/models.go b/sql/models.go index 36d4c07..93cecca 100644 --- a/sql/models.go +++ b/sql/models.go @@ -6,7 +6,6 @@ package sqlc import ( "database/sql/driver" - "encoding/json" "fmt" "time" @@ -224,11 +223,11 @@ type ReviewImage struct { } type Signal struct { - ID int64 `json:"id"` - TitleID *int64 `json:"title_id"` - RawData json.RawMessage `json:"raw_data"` - ProviderID int64 `json:"provider_id"` - Pending bool `json:"pending"` + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` + RawData []byte `json:"raw_data"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` } type Studio struct { @@ -239,13 +238,13 @@ type Studio struct { } type Tag struct { - ID int64 `json:"id"` - TagNames json.RawMessage `json:"tag_names"` + ID int64 `json:"id"` + TagNames []byte `json:"tag_names"` } type Title struct { ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` + TitleNames []byte `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 bf2f08a..4342a12 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,7 +7,6 @@ package sqlc import ( "context" - "encoding/json" "time" "github.com/jackc/pgx/v5/pgtype" @@ -121,10 +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, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, + 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, @@ -132,20 +128,20 @@ 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) LEFT JOIN images as si ON (s.illust_id = si.id) -WHERE t.id = $1::bigint +WHERE id = $1::bigint GROUP BY t.id, i.id, s.id, si.id ` type GetTitleByIDRow struct { ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` + TitleNames []byte `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -159,7 +155,7 @@ type GetTitleByIDRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` + TagNames []byte `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -228,15 +224,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) ([]json.RawMessage, error) { +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() - items := []json.RawMessage{} + items := [][]byte{} for rows.Next() { - var tag_names json.RawMessage + var tag_names []byte if err := rows.Scan(&tag_names); err != nil { return nil, err } @@ -313,7 +309,7 @@ VALUES ( RETURNING id, tag_names ` -func (q *Queries) InsertTag(ctx context.Context, tagNames json.RawMessage) (Tag, error) { +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) @@ -345,10 +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, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, + 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, @@ -356,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) @@ -428,16 +421,10 @@ WHERE END ) - 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) + 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 @@ -460,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 { @@ -470,7 +457,9 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - TitleStatuses []string `json:"title_statuses"` + 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"` @@ -479,7 +468,7 @@ type SearchTitlesParams struct { type SearchTitlesRow struct { ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` + TitleNames []byte `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -493,7 +482,7 @@ type SearchTitlesRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` + TagNames []byte `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -509,7 +498,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, - arg.TitleStatuses, + arg.Ongoing, + arg.Finished, + arg.Planned, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -557,195 +548,82 @@ 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, - 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(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 +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 ( - 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 - ) + 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 (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) +LIMIT COALESCE($7::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"` + 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 { - 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"` + 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.Forward, - arg.SortBy, - arg.CursorYear, - arg.CursorID, - arg.CursorRating, arg.Word, - arg.Ongoing, - arg.Planned, - arg.Dropped, - arg.InProgress, - arg.Finished, - arg.Rate, + arg.Status, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, + arg.UsertitleStatus, arg.Limit, ) if err != nil { @@ -756,6 +634,12 @@ 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, @@ -769,19 +653,6 @@ 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 } diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 3338c35..94b9fb4 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,8 +14,6 @@ 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: