Compare commits

..

No commits in common. "cbbc2c179d2421f98ad9857441ec5fd0d0b6fa12" and "89a05492c350433ee7d83a6256f5cca9e0fe2b06" have entirely different histories.

53 changed files with 1573 additions and 1864 deletions

View file

@ -158,12 +158,7 @@ paths:
- in: query - in: query
name: status name: status
schema: schema:
type: array $ref: '#/components/schemas/TitleStatus'
items:
$ref: '#/components/schemas/TitleStatus'
description: List of title statuses to filter
style: form
explode: false
- in: query - in: query
name: watch_status name: watch_status
schema: schema:
@ -429,8 +424,9 @@ components:
user_id: user_id:
type: integer type: integer
format: int64 format: int64
title: title_id:
$ref: '#/components/schemas/Title' type: integer
format: int64
status: status:
$ref: '#/components/schemas/UserTitleStatus' $ref: '#/components/schemas/UserTitleStatus'
rate: rate:

View file

@ -143,7 +143,7 @@ type UserTitle struct {
// Status User's title status // Status User's title status
Status UserTitleStatus `json:"status"` Status UserTitleStatus `json:"status"`
Title *Title `json:"title,omitempty"` TitleId int64 `json:"title_id"`
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
AdditionalProperties map[string]interface{} `json:"-"` AdditionalProperties map[string]interface{} `json:"-"`
} }
@ -183,11 +183,9 @@ type GetUsersUserIdParams struct {
// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles.
type GetUsersUserIdTitlesParams struct { type GetUsersUserIdTitlesParams struct {
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
Word *string `form:"word,omitempty" json:"word,omitempty"` Word *string `form:"word,omitempty" json:"word,omitempty"`
Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"`
// Status List of title statuses to filter
Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"`
WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"`
Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"`
ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,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") delete(object, "status")
} }
if raw, found := object["title"]; found { if raw, found := object["title_id"]; found {
err = json.Unmarshal(raw, &a.Title) err = json.Unmarshal(raw, &a.TitleId)
if err != nil { 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 { 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) return nil, fmt.Errorf("error marshaling 'status': %w", err)
} }
if a.Title != nil { object["title_id"], err = json.Marshal(a.TitleId)
object["title"], err = json.Marshal(a.Title) if err != nil {
if err != nil { return nil, fmt.Errorf("error marshaling 'title_id': %w", err)
return nil, fmt.Errorf("error marshaling 'title': %w", err)
}
} }
object["user_id"], err = json.Marshal(a.UserId) object["user_id"], err = json.Marshal(a.UserId)
@ -813,7 +809,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) {
// ------------- Optional query parameter "status" ------------- // ------------- Optional query parameter "status" -------------
err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), &params.Status) err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), &params.Status)
if err != nil { if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest)
return return

View file

@ -14,12 +14,7 @@ get:
- in: query - in: query
name: status name: status
schema: schema:
type: array $ref: '../schemas/enums/TitleStatus.yaml'
items:
$ref: '../schemas/enums/TitleStatus.yaml'
description: List of title statuses to filter
style: form
explode: false
- in: query - in: query
name: watch_status name: watch_status
schema: schema:

View file

@ -7,8 +7,9 @@ properties:
user_id: user_id:
type: integer type: integer
format: int64 format: int64
title: title_id:
$ref: ./Title.yaml type: integer
format: int64
status: status:
$ref: ./enums/UserTitleStatus.yaml $ref: ./enums/UserTitleStatus.yaml
rate: rate:

View file

@ -1,10 +1,6 @@
package handlers package handlers
import ( import (
"context"
"encoding/json"
"fmt"
oapi "nyanimedb/api"
sqlc "nyanimedb/sql" sqlc "nyanimedb/sql"
"strconv" "strconv"
) )
@ -17,75 +13,70 @@ func NewServer(db *sqlc.Queries) Server {
return Server{db: db} 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) // Values() map[string]interface{}
err := json.Unmarshal(title.TitleNames, &title_names) // // for logs only
if err != nil { // Type() string
return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) // }
}
episodes_lens := make(map[string]float64, 0) // type CursorByID struct {
err = json.Unmarshal(title.EpisodesLen, &episodes_lens) // ID int64
if err != nil { // }
return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err)
}
oapi_tag_names := make(oapi.Tags, 0) // func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) {
err = json.Unmarshal(title.TagNames, &oapi_tag_names) // var cur CursorByID
if err != nil { // if err := json.Unmarshal(data, &cur); err != nil {
return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) // 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 // func (c CursorByID) Type() string { return "id" }
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 // 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 { // decodes a base64-encoded JSON string into a CursorObj
oapi_image.Id = title.PosterID // Returns the parsed CursorObj and an error
oapi_image.ImagePath = title.TitleImagePath // func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) {
oapi_image.StorageType = &title.TitleStorageType
}
var release_season oapi.ReleaseSeason // // Decode base64
if title.ReleaseSeason != nil { // decoded, err := base64.StdEncoding.DecodeString(encoded)
release_season = oapi.ReleaseSeason(*title.ReleaseSeason) // if err != nil {
} // return nil, fmt.Errorf("parseCursor: %v", err)
// }
oapi_status, err := TitleStatus2oapi(&title.TitleStatus) // // Parse JSON
if err != nil { // var cursor oapi.CursorObj
return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) // if err := json.Unmarshal(decoded, &cursor); err != nil {
} // return nil, fmt.Errorf("parseCursor: %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 // return &cursor, nil
} // }
func parseInt64(s string) (int32, error) { func parseInt64(s string) (int32, error) {
i, err := strconv.ParseInt(s, 10, 64) i, err := strconv.ParseInt(s, 10, 64)

View file

@ -26,12 +26,12 @@ type SqlcStatus struct {
planned string planned string
} }
func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { func TitleStatus2Sqlc(s []oapi.TitleStatus) (*SqlcStatus, error) {
var sqlc_status SqlcStatus
if s == nil { if s == nil {
return &sqlc_status, nil return nil, nil
} }
for _, t := range *s { var sqlc_status SqlcStatus
for _, t := range s {
switch t { switch t {
case oapi.TitleStatusFinished: case oapi.TitleStatusFinished:
sqlc_status.finished = "finished" sqlc_status.finished = "finished"
@ -158,6 +158,78 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) {
return &oapi_studio, nil 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) { func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) {
var oapi_title oapi.Title 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) opai_titles := make([]oapi.Title, 0)
word := Word2Sqlc(request.Params.Word) word := Word2Sqlc(request.Params.Word)
status, err := TitleStatus2Sqlc(request.Params.Status) status, err := TitleStatus2Sqlc(*request.Params.Status)
if err != nil { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitles400Response{}, err return oapi.GetTitles400Response{}, err
} }
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
if err != nil { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
@ -267,7 +338,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
if request.Params.Sort != nil { if request.Params.Sort != nil {
switch string(*request.Params.Sort) { switch string(*request.Params.Sort) {
case "year": case "year":
tmp := fmt.Sprint(*t.ReleaseYear) tmp := fmt.Sprint("%d", *t.ReleaseYear)
new_cursor.Param = &tmp new_cursor.Param = &tmp
case "rating": case "rating":
tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64)

View file

@ -2,15 +2,12 @@ package handlers
import ( import (
"context" "context"
"fmt"
oapi "nyanimedb/api" oapi "nyanimedb/api"
sqlc "nyanimedb/sql" sqlc "nyanimedb/sql"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/oapi-codegen/runtime/types" "github.com/oapi-codegen/runtime/types"
log "github.com/sirupsen/logrus"
) )
// type Server struct { // type Server struct {
@ -53,120 +50,33 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque
return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil 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) { 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) var userTitles = []oapi.UserTitle{
status, err := TitleStatus2Sqlc(request.Params.Status) {
if err != nil { UserId: 101,
log.Errorf("%v", err) TitleId: 2001,
return oapi.GetUsersUserIdTitles400Response{}, err Status: oapi.UserTitleStatusFinished,
} Rate: &rate,
Ctime: &time,
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) },
if err != nil { {
log.Errorf("%v", err) UserId: 102,
return oapi.GetUsersUserIdTitles400Response{}, err TitleId: 2002,
} Status: oapi.UserTitleStatusInProgress,
ReviewId: &review_id,
params := sqlc.SearchUserTitlesParams{ Ctime: &time,
Forward: true, },
SortBy: "id", {
// CursorYear : UserId: 103,
// CursorID *int64 `json:"cursor_id"` TitleId: 2003,
// CursorRating *float64 `json:"cursor_rating"` Status: oapi.UserTitleStatusDropped,
// Word *string `json:"word"` Ctime: &time,
// 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), &params)
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,
}
} }
return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil

View file

@ -78,10 +78,7 @@ SELECT
t.*, t.*,
i.storage_type::text as title_storage_type, i.storage_type::text as title_storage_type,
i.image_path as title_image_path, i.image_path as title_image_path,
COALESCE( jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names,
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
'[]'::jsonb
)::jsonb as tag_names,
s.studio_name as studio_name, s.studio_name as studio_name,
s.illust_id as studio_illust_id, s.illust_id as studio_illust_id,
s.studio_desc as studio_desc, s.studio_desc as studio_desc,
@ -89,13 +86,13 @@ SELECT
si.image_path as studio_image_path si.image_path as studio_image_path
FROM titles as t 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 title_tags as tt ON (t.id = tt.title_id)
LEFT JOIN tags as g ON (tt.tag_id = g.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 studios as s ON (t.studio_id = s.id)
LEFT JOIN images as si ON (s.illust_id = si.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 GROUP BY
t.id, i.id, s.id, si.id; t.id, i.id, s.id, si.id;
@ -104,10 +101,7 @@ SELECT
t.*, t.*,
i.storage_type::text as title_storage_type, i.storage_type::text as title_storage_type,
i.image_path as title_image_path, i.image_path as title_image_path,
COALESCE( jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names,
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
'[]'::jsonb
)::jsonb as tag_names,
s.studio_name as studio_name, s.studio_name as studio_name,
s.illust_id as studio_illust_id, s.illust_id as studio_illust_id,
s.studio_desc as studio_desc, s.studio_desc as studio_desc,
@ -115,7 +109,7 @@ SELECT
si.image_path as studio_image_path si.image_path as studio_image_path
FROM titles as t 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 title_tags as tt ON (t.id = tt.title_id)
LEFT JOIN tags as g ON (tt.tag_id = g.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 studios as s ON (t.studio_id = s.id)
@ -187,13 +181,7 @@ WHERE
END END
) )
AND ( AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text))
-- Если массив пуст (NULL или []) — не фильтруем
cardinality(sqlc.arg('title_statuses')::text[]) = 0
OR
-- Иначе: статус есть в списке
t.title_status = ANY(sqlc.arg('title_statuses')::text[])
)
AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('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_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int)
AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) AND (sqlc.narg('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 LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
-- name: SearchUserTitles :many -- name: SearchUserTitles :many
SELECT SELECT
t.*, *
u.user_id as user_id, FROM usertitles as u
u.status as usertitle_status, JOIN titles as t ON (u.title_id = t.id)
u.rate as user_rate, WHERE
u.review_id as review_id, CASE
u.ctime as user_ctime, WHEN sqlc.narg('word')::text IS NOT NULL THEN
i.storage_type::text as title_storage_type, (
i.image_path as title_image_path, SELECT bool_and(
jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, EXISTS (
s.studio_name as studio_name, SELECT 1
s.illust_id as studio_illust_id, FROM jsonb_each_text(t.title_names) AS t(key, val)
s.studio_desc as studio_desc, WHERE val ILIKE pattern
si.storage_type::text as studio_storage_type, )
si.image_path as studio_image_path )
FROM unnest(
FROM usertitles as u ARRAY(
LEFT JOIN titles as t ON (u.title_id = t.id) SELECT '%' || trim(w) || '%'
LEFT JOIN images as i ON (t.poster_id = i.id) FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
LEFT JOIN title_tags as tt ON (t.id = tt.title_id) WHERE trim(w) <> ''
LEFT JOIN tags as g ON (tt.tag_id = g.id) )
LEFT JOIN studios as s ON (t.studio_id = s.id) ) AS pattern
LEFT JOIN images as si ON (s.illust_id = si.id) )
ELSE true
WHERE
CASE
WHEN sqlc.arg('forward')::boolean THEN
-- forward: greater than cursor (next page)
CASE sqlc.arg('sort_by')::text
WHEN 'year' THEN
(sqlc.narg('cursor_year')::int IS NULL) OR
(t.release_year > sqlc.narg('cursor_year')::int) OR
(t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint)
WHEN 'rating' THEN
(sqlc.narg('cursor_rating')::float IS NULL) OR
(t.rating > sqlc.narg('cursor_rating')::float) OR
(t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint)
WHEN 'id' THEN
(sqlc.narg('cursor_id')::bigint IS NULL) OR
(t.id > sqlc.narg('cursor_id')::bigint)
ELSE true -- fallback
END
ELSE
-- backward: less than cursor (prev page)
CASE sqlc.arg('sort_by')::text
WHEN 'year' THEN
(sqlc.narg('cursor_year')::int IS NULL) OR
(t.release_year < sqlc.narg('cursor_year')::int) OR
(t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint)
WHEN 'rating' THEN
(sqlc.narg('cursor_rating')::float IS NULL) OR
(t.rating < sqlc.narg('cursor_rating')::float) OR
(t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint)
WHEN 'id' THEN
(sqlc.narg('cursor_id')::bigint IS NULL) OR
(t.id < sqlc.narg('cursor_id')::bigint)
ELSE true
END
END END
AND ( AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t)
CASE
WHEN sqlc.narg('word')::text IS NOT NULL THEN
(
SELECT bool_and(
EXISTS (
SELECT 1
FROM jsonb_each_text(t.title_names) AS t(key, val)
WHERE val ILIKE pattern
)
)
FROM unnest(
ARRAY(
SELECT '%' || trim(w) || '%'
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
WHERE trim(w) <> ''
)
) AS pattern
)
ELSE true
END
)
AND (u.status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('planned')::text, sqlc.arg('dropped')::text, sqlc.arg('in-progress')::text))
AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text))
AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int)
AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('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_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int)
AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t)
AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t)
GROUP BY
t.id, i.id, s.id, si.id
ORDER BY
CASE WHEN sqlc.arg('forward')::boolean THEN
CASE
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate
END
END ASC,
CASE WHEN NOT sqlc.arg('forward')::boolean THEN
CASE
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate
END
END DESC,
CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit

File diff suppressed because it is too large Load diff

View file

@ -10,14 +10,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4"
"tailwindcss": "^4.1.17"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View file

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

View file

@ -2,13 +2,10 @@ import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import UserPage from "./pages/UserPage/UserPage"; import UserPage from "./pages/UserPage/UserPage";
import TitlesPage from "./pages/TitlesPage/TitlesPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage";
import { Header } from "./components/Header/Header";
const App: React.FC = () => { const App: React.FC = () => {
const username = "nihonium";
return ( return (
<Router> <Router>
<Header username={username} />
<Routes> <Routes>
<Route path="/users/:id" element={<UserPage />} /> <Route path="/users/:id" element={<UserPage />} />
<Route path="/titles" element={<TitlesPage />} /> <Route path="/titles" element={<TitlesPage />} />

View file

@ -8,19 +8,16 @@ export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI';
export type { cursor } from './models/cursor'; export type { cursor } from './models/cursor';
export type { CursorObj } from './models/CursorObj';
export type { Image } from './models/Image'; 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 { Review } from './models/Review';
export type { Studio } from './models/Studio'; export type { Studio } from './models/Studio';
export type { Tag } from './models/Tag'; export type { Tag } from './models/Tag';
export type { Tags } from './models/Tags'; export type { Tags } from './models/Tags';
export type { Title } from './models/Title'; export type { Title } from './models/Title';
export type { title_sort } from './models/title_sort'; export { TitleStatus } from './models/TitleStatus';
export type { TitleSort } from './models/TitleSort';
export type { TitleStatus } from './models/TitleStatus';
export type { User } from './models/User'; export type { User } from './models/User';
export type { UserTitle } from './models/UserTitle'; export type { UserTitle } from './models/UserTitle';
export type { UserTitleStatus } from './models/UserTitleStatus'; export { UserTitleStatus } from './models/UserTitleStatus';
export { DefaultService } from './services/DefaultService'; export { DefaultService } from './services/DefaultService';

View file

@ -5,4 +5,9 @@
/** /**
* Title release season * Title release season
*/ */
export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; export enum ReleaseSeason {
WINTER = 'winter',
SPRING = 'spring',
SUMMER = 'summer',
FALL = 'fall',
}

View file

@ -5,4 +5,8 @@
/** /**
* Title status * Title status
*/ */
export type TitleStatus = 'finished' | 'ongoing' | 'planned'; export enum TitleStatus {
FINISHED = 'finished',
ONGOING = 'ongoing',
PLANNED = 'planned',
}

View file

@ -6,11 +6,11 @@ export type User = {
/** /**
* Unique user ID (primary key) * Unique user ID (primary key)
*/ */
id?: number; id: number;
/** /**
* ID of the user avatar (references images table) * ID of the user avatar (references images table)
*/ */
avatar_id?: number; avatar_id?: number | null;
/** /**
* User email * User email
*/ */

View file

@ -5,4 +5,9 @@
/** /**
* User's title status * 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',
}

View file

@ -2,23 +2,17 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { CursorObj } from '../models/CursorObj';
import type { ReleaseSeason } from '../models/ReleaseSeason'; import type { ReleaseSeason } from '../models/ReleaseSeason';
import type { Title } from '../models/Title'; import type { Title } from '../models/Title';
import type { TitleSort } from '../models/TitleSort';
import type { TitleStatus } from '../models/TitleStatus'; import type { TitleStatus } from '../models/TitleStatus';
import type { User } from '../models/User'; import type { User } from '../models/User';
import type { UserTitle } from '../models/UserTitle'; import type { UserTitle } from '../models/UserTitle';
import type { UserTitleStatus } from '../models/UserTitleStatus';
import type { CancelablePromise } from '../core/CancelablePromise'; import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI'; import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request'; import { request as __request } from '../core/request';
export class DefaultService { export class DefaultService {
/** /**
* Get titles * Get titles
* @param cursor
* @param sort
* @param sortForward
* @param word * @param word
* @param status * @param status
* @param rating * @param rating
@ -27,13 +21,10 @@ export class DefaultService {
* @param limit * @param limit
* @param offset * @param offset
* @param fields * @param fields
* @returns any List of titles with cursor * @returns Title List of titles
* @throws ApiError * @throws ApiError
*/ */
public static getTitles( public static getTitles(
cursor?: string,
sort?: TitleSort,
sortForward: boolean = true,
word?: string, word?: string,
status?: TitleStatus, status?: TitleStatus,
rating?: number, rating?: number,
@ -42,20 +33,11 @@ export class DefaultService {
limit: number = 10, limit: number = 10,
offset?: number, offset?: number,
fields: string = 'all', fields: string = 'all',
): CancelablePromise<{ ): CancelablePromise<Array<Title>> {
/**
* List of titles
*/
data: Array<Title>;
cursor: CursorObj;
}> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/titles', url: '/titles',
query: { query: {
'cursor': cursor,
'sort': sort,
'sort_forward': sortForward,
'word': word, 'word': word,
'status': status, 'status': status,
'rating': rating, 'rating': rating,
@ -129,13 +111,9 @@ export class DefaultService {
* Get user titles * Get user titles
* @param userId * @param userId
* @param cursor * @param cursor
* @param word * @param query
* @param status
* @param watchStatus
* @param rating
* @param releaseYear
* @param releaseSeason
* @param limit * @param limit
* @param offset
* @param fields * @param fields
* @returns UserTitle List of user titles * @returns UserTitle List of user titles
* @throws ApiError * @throws ApiError
@ -143,30 +121,22 @@ export class DefaultService {
public static getUsersTitles( public static getUsersTitles(
userId: string, userId: string,
cursor?: string, cursor?: string,
word?: string, query?: string,
status?: TitleStatus,
watchStatus?: UserTitleStatus,
rating?: number,
releaseYear?: number,
releaseSeason?: ReleaseSeason,
limit: number = 10, limit: number = 10,
offset?: number,
fields: string = 'all', fields: string = 'all',
): CancelablePromise<Array<UserTitle>> { ): CancelablePromise<Array<UserTitle>> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/users/{user_id}/titles/', url: '/users/{user_id}/titles',
path: { path: {
'user_id': userId, 'user_id': userId,
}, },
query: { query: {
'cursor': cursor, 'cursor': cursor,
'word': word, 'query': query,
'status': status,
'watch_status': watchStatus,
'rating': rating,
'release_year': releaseYear,
'release_season': releaseSeason,
'limit': limit, 'limit': limit,
'offset': offset,
'fields': fields, 'fields': fields,
}, },
errors: { errors: {

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
};

View file

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

View file

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

View file

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

View file

@ -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',
}

View file

@ -2,8 +2,4 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type CursorObj = { export type Review = Record<string, any>;
id: number;
param?: string;
};

View file

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

View file

@ -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>;

View file

@ -2,7 +2,8 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-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>;

View file

@ -2,5 +2,4 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { TitleSort } from './TitleSort'; export type Title = Record<string, any>;
export type title_sort = TitleSort;

View file

@ -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',
}

View file

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

View file

@ -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>;

View file

@ -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',
}

View file

@ -0,0 +1,5 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type cursor = string;

View file

@ -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`,
},
});
}
}

View file

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

View file

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

View file

@ -1,48 +1,52 @@
import React from "react"; import React from "react";
export type ListViewProps<T> = { interface ListViewProps<TItem> {
items: T[]; hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>;
layout: "square" | "horizontal"; renderHorizontal: (item: TItem) => React.ReactNode;
renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; renderSquare: (item: TItem) => React.ReactNode;
onLoadMore: () => void; }
hasMore: boolean;
loadingMore: boolean;
};
export function ListView<T>({ export function ListView<TItem>({
items, hook,
layout, renderHorizontal,
renderItem, renderSquare
onLoadMore, }: ListViewProps<TItem>) {
hasMore, const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook;
loadingMore
}: ListViewProps<T>) {
return ( return (
<div className="w-full flex flex-col items-center"> <div>
{/* Items */} {/* Search + Layout Switcher */}
<div <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
className={`w-full sm:w-4/5 grid gap-6 ${ <input
layout === "square" placeholder="Search..."
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" value={search}
: "grid-cols-1" onChange={e => setSearch(e.target.value)}
}`} />
>
{items.map(item => renderItem(item, layout))} <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> </div>
{/* Load More */}
{hasMore && ( {hasMore && (
<div className="mt-8"> <button onClick={loadMore} style={{ marginTop: 16 }}>
<button Load More
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50" </button>
disabled={loadingMore}
onClick={onLoadMore}
>
{loadingMore ? "Loading..." : "Load More"}
</button>
</div>
)} )}
</div> </div>
); );
} }

View file

@ -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,
};
}

View file

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

View file

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

View file

@ -9,13 +9,13 @@ export function TitleCardHorizontal({ title }: { title: Title }) {
border: "1px solid #ddd", border: "1px solid #ddd",
borderRadius: 8 borderRadius: 8
}}> }}>
{title.poster?.image_path && ( {title.posterUrl && (
<img src={title.poster.image_path} width={80} /> <img src={title.posterUrl} width={80} />
)} )}
<div> <div>
<h3>{title.title_names["en"]}</h3> <h3>{title.name}</h3>
<p>{title.release_year} · {title.release_season} · Rating: {title.rating}</p> <p>{title.year} · {title.season} · Rating: {title.rating}</p>
<p>Status: {title.title_status}</p> <p>Status: {title.status}</p>
</div> </div>
</div> </div>
); );

View file

@ -10,12 +10,12 @@ export function TitleCardSquare({ title }: { title: Title }) {
borderRadius: 8, borderRadius: 8,
textAlign: "center" textAlign: "center"
}}> }}>
{title.poster?.image_path && ( {title.posterUrl && (
<img src={title.poster.image_path} width={140} /> <img src={title.posterUrl} width={140} />
)} )}
<div> <div>
<h4>{title.title_names["en"]}</h4> <h4>{title.name}</h4>
<small>{title.release_year} {title.rating}</small> <small>{title.year} {title.rating}</small>
</div> </div>
</div> </div>
); );

View file

@ -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; margin: 0;
padding: 0; display: flex;
width: 100%; place-items: center;
height: 100%; min-width: 320px;
@apply text-black bg-white; 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;
}
}

View file

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

View file

@ -1,154 +1,114 @@
import { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { ListView } from "../../components/ListView/ListView";
import { SearchBar } from "../../components/SearchBar/SearchBar";
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox";
import { DefaultService } from "../../api/services/DefaultService"; import { DefaultService } from "../../api/services/DefaultService";
import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import type { Title } from "../../api/models/Title";
import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import styles from "./TitlesPage.module.css";
import type { CursorObj, Title, TitleSort } from "../../api";
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
const PAGE_SIZE = 10; const LIMIT = 20;
export default function TitlesPage() { const TitlesPage: React.FC = () => {
const [titles, setTitles] = useState<Title[]>([]); const [titles, setTitles] = useState<Title[]>([]);
const [nextPage, setNextPage] = useState<Title[]>([]);
const [cursor, setCursor] = useState<CursorObj | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [sort, setSort] = useState<TitleSort>("id"); const [error, setError] = useState<string | null>(null);
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 fetchTitles = async (reset: boolean) => {
try { try {
if (reset) {
setLoading(true);
setOffset(0);
} else {
setLoadingMore(true);
}
const result = await DefaultService.getTitles( const result = await DefaultService.getTitles(
cursorStr, search || undefined,
sort, undefined, // status
sortForward, undefined, // rating
search.trim() || undefined, undefined, // release_year
undefined, undefined, // release_season
undefined, LIMIT,
undefined, reset ? 0 : offset,
undefined,
PAGE_SIZE,
undefined,
"all" "all"
); );
if ((result === undefined) || !result.data?.length) { if (reset) {
return { items: [], nextCursor: null }; setTitles(result);
} else {
setTitles(prev => [...prev, ...result]);
} }
return {
items: result.data ?? [], if (result.length > 0) {
nextCursor: result.cursor ?? null setOffset(prev => prev + LIMIT);
};
} catch (err: any) {
if (err.status === 204) {
return { items: [], nextCursor: null };
} }
throw err;
} catch (err) {
console.error(err);
setError("Failed to fetch titles.");
} finally {
setLoading(false);
setLoadingMore(false);
} }
}; };
// Инициализация: загружаем сразу две страницы
useEffect(() => { useEffect(() => {
const initLoad = async () => { fetchTitles(true);
setLoading(true); }, [search]);
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);
};
if (loading) return <div className={styles.loader}>Loading...</div>;
if (error) return <div className={styles.error}>{error}</div>;
return ( 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> <input
className={styles.searchInput}
<div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> placeholder="Search titles..."
<SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> value={search}
<LayoutSwitch layout={layout} setLayout={setLayout} /> onChange={e => setSearch(e.target.value)}
<TitlesSortBox
sort={sort}
setSort={setSort}
sortForward={sortForward}
setSortForward={setSortForward}
/> />
</div> </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={styles.cardInfo}>
<div className="mt-20 font-medium text-black">No titles found.</div> <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 && ( {titles.length > 0 && (
<> <button
<ListView<Title> className={styles.loadMore}
items={titles} onClick={() => fetchTitles(false)}
layout={layout} disabled={loadingMore}
hasMore={!!cursor || nextPage.length > 1} >
loadingMore={loadingMore} {loadingMore ? "Loading..." : "Load More"}
onLoadMore={handleLoadMore} </button>
renderItem={(title, layout) =>
layout === "square"
? <TitleCardSquare title={title} />
: <TitleCardHorizontal title={title} />
}
/>
{!cursor && nextPage.length == 0 && (
<div className="mt-6 font-medium text-black">
Результатов больше нет, было найдено {titles.length} тайтлов.
</div>
)}
</>
)} )}
</div> </div>
); );
} };
export default TitlesPage;

View file

@ -1,15 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react()],
react(),
tailwindcss()
],
server: { server: {
host: '0.0.0.0', host: '127.0.0.1',
port: 8083, port: 8083,
}, },
}) })

View file

@ -6,7 +6,6 @@ package sqlc
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json"
"fmt" "fmt"
"time" "time"
@ -224,11 +223,11 @@ type ReviewImage struct {
} }
type Signal struct { type Signal struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TitleID *int64 `json:"title_id"` TitleID *int64 `json:"title_id"`
RawData json.RawMessage `json:"raw_data"` RawData []byte `json:"raw_data"`
ProviderID int64 `json:"provider_id"` ProviderID int64 `json:"provider_id"`
Pending bool `json:"pending"` Pending bool `json:"pending"`
} }
type Studio struct { type Studio struct {
@ -239,13 +238,13 @@ type Studio struct {
} }
type Tag struct { type Tag struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TagNames json.RawMessage `json:"tag_names"` TagNames []byte `json:"tag_names"`
} }
type Title struct { type Title struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TitleNames json.RawMessage `json:"title_names"` TitleNames []byte `json:"title_names"`
StudioID int64 `json:"studio_id"` StudioID int64 `json:"studio_id"`
PosterID *int64 `json:"poster_id"` PosterID *int64 `json:"poster_id"`
TitleStatus TitleStatusT `json:"title_status"` TitleStatus TitleStatusT `json:"title_status"`

View file

@ -7,7 +7,6 @@ package sqlc
import ( import (
"context" "context"
"encoding/json"
"time" "time"
"github.com/jackc/pgx/v5/pgtype" "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, t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len,
i.storage_type::text as title_storage_type, i.storage_type::text as title_storage_type,
i.image_path as title_image_path, i.image_path as title_image_path,
COALESCE( jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names,
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
'[]'::jsonb
)::jsonb as tag_names,
s.studio_name as studio_name, s.studio_name as studio_name,
s.illust_id as studio_illust_id, s.illust_id as studio_illust_id,
s.studio_desc as studio_desc, s.studio_desc as studio_desc,
@ -132,20 +128,20 @@ SELECT
si.image_path as studio_image_path si.image_path as studio_image_path
FROM titles as t 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 title_tags as tt ON (t.id = tt.title_id)
LEFT JOIN tags as g ON (tt.tag_id = g.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 studios as s ON (t.studio_id = s.id)
LEFT JOIN images as si ON (s.illust_id = si.id) LEFT JOIN images as si ON (s.illust_id = si.id)
WHERE t.id = $1::bigint WHERE id = $1::bigint
GROUP BY GROUP BY
t.id, i.id, s.id, si.id t.id, i.id, s.id, si.id
` `
type GetTitleByIDRow struct { type GetTitleByIDRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TitleNames json.RawMessage `json:"title_names"` TitleNames []byte `json:"title_names"`
StudioID int64 `json:"studio_id"` StudioID int64 `json:"studio_id"`
PosterID *int64 `json:"poster_id"` PosterID *int64 `json:"poster_id"`
TitleStatus TitleStatusT `json:"title_status"` TitleStatus TitleStatusT `json:"title_status"`
@ -159,7 +155,7 @@ type GetTitleByIDRow struct {
EpisodesLen []byte `json:"episodes_len"` EpisodesLen []byte `json:"episodes_len"`
TitleStorageType string `json:"title_storage_type"` TitleStorageType string `json:"title_storage_type"`
TitleImagePath *string `json:"title_image_path"` TitleImagePath *string `json:"title_image_path"`
TagNames json.RawMessage `json:"tag_names"` TagNames []byte `json:"tag_names"`
StudioName *string `json:"studio_name"` StudioName *string `json:"studio_name"`
StudioIllustID *int64 `json:"studio_illust_id"` StudioIllustID *int64 `json:"studio_illust_id"`
StudioDesc *string `json:"studio_desc"` 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 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) rows, err := q.db.Query(ctx, getTitleTags, titleID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
items := []json.RawMessage{} items := [][]byte{}
for rows.Next() { for rows.Next() {
var tag_names json.RawMessage var tag_names []byte
if err := rows.Scan(&tag_names); err != nil { if err := rows.Scan(&tag_names); err != nil {
return nil, err return nil, err
} }
@ -313,7 +309,7 @@ VALUES (
RETURNING id, tag_names 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) row := q.db.QueryRow(ctx, insertTag, tagNames)
var i Tag var i Tag
err := row.Scan(&i.ID, &i.TagNames) 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, t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len,
i.storage_type::text as title_storage_type, i.storage_type::text as title_storage_type,
i.image_path as title_image_path, i.image_path as title_image_path,
COALESCE( jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names,
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
'[]'::jsonb
)::jsonb as tag_names,
s.studio_name as studio_name, s.studio_name as studio_name,
s.illust_id as studio_illust_id, s.illust_id as studio_illust_id,
s.studio_desc as studio_desc, s.studio_desc as studio_desc,
@ -356,7 +349,7 @@ SELECT
si.image_path as studio_image_path si.image_path as studio_image_path
FROM titles as t 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 title_tags as tt ON (t.id = tt.title_id)
LEFT JOIN tags as g ON (tt.tag_id = g.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 studios as s ON (t.studio_id = s.id)
@ -428,16 +421,10 @@ WHERE
END END
) )
AND ( AND (t.title_status::text IN ($7::text, $8::text, $9::text))
-- Если массив пуст (NULL или []) не фильтруем AND ($10::float IS NULL OR t.rating >= $10::float)
cardinality($7::text[]) = 0 AND ($11::int IS NULL OR t.release_year = $11::int)
OR AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t)
-- Иначе: статус есть в списке
t.title_status = ANY($7::text[])
)
AND ($8::float IS NULL OR t.rating >= $8::float)
AND ($9::int IS NULL OR t.release_year = $9::int)
AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t)
GROUP BY GROUP BY
t.id, i.id, s.id, si.id t.id, i.id, s.id, si.id
@ -460,7 +447,7 @@ ORDER BY
CASE WHEN $2::text <> 'id' THEN t.id END ASC CASE WHEN $2::text <> 'id' THEN t.id END ASC
LIMIT COALESCE($11::int, 100) LIMIT COALESCE($13::int, 100)
` `
type SearchTitlesParams struct { type SearchTitlesParams struct {
@ -470,7 +457,9 @@ type SearchTitlesParams struct {
CursorID *int64 `json:"cursor_id"` CursorID *int64 `json:"cursor_id"`
CursorRating *float64 `json:"cursor_rating"` CursorRating *float64 `json:"cursor_rating"`
Word *string `json:"word"` 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"` Rating *float64 `json:"rating"`
ReleaseYear *int32 `json:"release_year"` ReleaseYear *int32 `json:"release_year"`
ReleaseSeason *ReleaseSeasonT `json:"release_season"` ReleaseSeason *ReleaseSeasonT `json:"release_season"`
@ -479,7 +468,7 @@ type SearchTitlesParams struct {
type SearchTitlesRow struct { type SearchTitlesRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TitleNames json.RawMessage `json:"title_names"` TitleNames []byte `json:"title_names"`
StudioID int64 `json:"studio_id"` StudioID int64 `json:"studio_id"`
PosterID *int64 `json:"poster_id"` PosterID *int64 `json:"poster_id"`
TitleStatus TitleStatusT `json:"title_status"` TitleStatus TitleStatusT `json:"title_status"`
@ -493,7 +482,7 @@ type SearchTitlesRow struct {
EpisodesLen []byte `json:"episodes_len"` EpisodesLen []byte `json:"episodes_len"`
TitleStorageType string `json:"title_storage_type"` TitleStorageType string `json:"title_storage_type"`
TitleImagePath *string `json:"title_image_path"` TitleImagePath *string `json:"title_image_path"`
TagNames json.RawMessage `json:"tag_names"` TagNames []byte `json:"tag_names"`
StudioName *string `json:"studio_name"` StudioName *string `json:"studio_name"`
StudioIllustID *int64 `json:"studio_illust_id"` StudioIllustID *int64 `json:"studio_illust_id"`
StudioDesc *string `json:"studio_desc"` StudioDesc *string `json:"studio_desc"`
@ -509,7 +498,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S
arg.CursorID, arg.CursorID,
arg.CursorRating, arg.CursorRating,
arg.Word, arg.Word,
arg.TitleStatuses, arg.Ongoing,
arg.Finished,
arg.Planned,
arg.Rating, arg.Rating,
arg.ReleaseYear, arg.ReleaseYear,
arg.ReleaseSeason, arg.ReleaseSeason,
@ -557,195 +548,82 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S
const searchUserTitles = `-- name: SearchUserTitles :many const searchUserTitles = `-- name: SearchUserTitles :many
SELECT 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, 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
u.user_id as user_id, FROM usertitles as u
u.status as usertitle_status, JOIN titles as t ON (u.title_id = t.id)
u.rate as user_rate, WHERE
u.review_id as review_id, CASE
u.ctime as user_ctime, WHEN $1::text IS NOT NULL THEN
i.storage_type::text as title_storage_type, (
i.image_path as title_image_path, SELECT bool_and(
jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, EXISTS (
s.studio_name as studio_name, SELECT 1
s.illust_id as studio_illust_id, FROM jsonb_each_text(t.title_names) AS t(key, val)
s.studio_desc as studio_desc, WHERE val ILIKE pattern
si.storage_type::text as studio_storage_type, )
si.image_path as studio_image_path )
FROM unnest(
FROM usertitles as u ARRAY(
LEFT JOIN titles as t ON (u.title_id = t.id) SELECT '%' || trim(w) || '%'
LEFT JOIN images as i ON (t.poster_id = i.id) FROM unnest(string_to_array($1::text, ' ')) AS w
LEFT JOIN title_tags as tt ON (t.id = tt.title_id) WHERE trim(w) <> ''
LEFT JOIN tags as g ON (tt.tag_id = g.id) )
LEFT JOIN studios as s ON (t.studio_id = s.id) ) AS pattern
LEFT JOIN images as si ON (s.illust_id = si.id) )
ELSE true
WHERE
CASE
WHEN $1::boolean THEN
-- forward: greater than cursor (next page)
CASE $2::text
WHEN 'year' THEN
($3::int IS NULL) OR
(t.release_year > $3::int) OR
(t.release_year = $3::int AND t.id > $4::bigint)
WHEN 'rating' THEN
($5::float IS NULL) OR
(t.rating > $5::float) OR
(t.rating = $5::float AND t.id > $4::bigint)
WHEN 'id' THEN
($4::bigint IS NULL) OR
(t.id > $4::bigint)
ELSE true -- fallback
END
ELSE
-- backward: less than cursor (prev page)
CASE $2::text
WHEN 'year' THEN
($3::int IS NULL) OR
(t.release_year < $3::int) OR
(t.release_year = $3::int AND t.id < $4::bigint)
WHEN 'rating' THEN
($5::float IS NULL) OR
(t.rating < $5::float) OR
(t.rating = $5::float AND t.id < $4::bigint)
WHEN 'id' THEN
($4::bigint IS NULL) OR
(t.id < $4::bigint)
ELSE true
END
END END
AND ( AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t)
CASE AND ($3::float IS NULL OR t.rating >= $3::float)
WHEN $6::text IS NOT NULL THEN 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)
SELECT bool_and( AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t)
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 (u.status::text IN ($7::text, $8::text, $9::text, $10::text)) LIMIT COALESCE($7::int, 100)
AND (t.title_status::text IN ($7::text, $11::text, $8::text))
AND ($12::int IS NULL OR u.rate >= $12::int)
AND ($13::float IS NULL OR t.rating >= $13::float)
AND ($14::int IS NULL OR t.release_year = $14::int)
AND ($15::release_season_t IS NULL OR t.release_season = $15::release_season_t)
GROUP BY
t.id, i.id, s.id, si.id
ORDER BY
CASE WHEN $1::boolean THEN
CASE
WHEN $2::text = 'id' THEN t.id
WHEN $2::text = 'year' THEN t.release_year
WHEN $2::text = 'rating' THEN t.rating
WHEN $2::text = 'rate' THEN u.rate
END
END ASC,
CASE WHEN NOT $1::boolean THEN
CASE
WHEN $2::text = 'id' THEN t.id
WHEN $2::text = 'year' THEN t.release_year
WHEN $2::text = 'rating' THEN t.rating
WHEN $2::text = 'rate' THEN u.rate
END
END DESC,
CASE WHEN $2::text <> 'id' THEN t.id END ASC
LIMIT COALESCE($16::int, 100)
` `
type SearchUserTitlesParams struct { type SearchUserTitlesParams struct {
Forward bool `json:"forward"` Word *string `json:"word"`
SortBy string `json:"sort_by"` Status *TitleStatusT `json:"status"`
CursorYear *int32 `json:"cursor_year"` Rating *float64 `json:"rating"`
CursorID *int64 `json:"cursor_id"` ReleaseYear *int32 `json:"release_year"`
CursorRating *float64 `json:"cursor_rating"` ReleaseSeason *ReleaseSeasonT `json:"release_season"`
Word *string `json:"word"` UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"`
Ongoing string `json:"ongoing"` Limit *int32 `json:"limit"`
Planned string `json:"planned"`
Dropped string `json:"dropped"`
InProgress string `json:"in-progress"`
Finished string `json:"finished"`
Rate *int32 `json:"rate"`
Rating *float64 `json:"rating"`
ReleaseYear *int32 `json:"release_year"`
ReleaseSeason *ReleaseSeasonT `json:"release_season"`
Limit *int32 `json:"limit"`
} }
type SearchUserTitlesRow struct { type SearchUserTitlesRow struct {
ID *int64 `json:"id"` UserID int64 `json:"user_id"`
TitleNames []byte `json:"title_names"` TitleID int64 `json:"title_id"`
StudioID *int64 `json:"studio_id"` Status UsertitleStatusT `json:"status"`
PosterID *int64 `json:"poster_id"` Rate *int32 `json:"rate"`
TitleStatus *TitleStatusT `json:"title_status"` ReviewID *int64 `json:"review_id"`
Rating *float64 `json:"rating"` Ctime pgtype.Timestamptz `json:"ctime"`
RatingCount *int32 `json:"rating_count"` ID int64 `json:"id"`
ReleaseYear *int32 `json:"release_year"` TitleNames []byte `json:"title_names"`
ReleaseSeason *ReleaseSeasonT `json:"release_season"` StudioID int64 `json:"studio_id"`
Season *int32 `json:"season"` PosterID *int64 `json:"poster_id"`
EpisodesAired *int32 `json:"episodes_aired"` TitleStatus TitleStatusT `json:"title_status"`
EpisodesAll *int32 `json:"episodes_all"` Rating *float64 `json:"rating"`
EpisodesLen []byte `json:"episodes_len"` RatingCount *int32 `json:"rating_count"`
UserID int64 `json:"user_id"` ReleaseYear *int32 `json:"release_year"`
UsertitleStatus UsertitleStatusT `json:"usertitle_status"` ReleaseSeason *ReleaseSeasonT `json:"release_season"`
UserRate *int32 `json:"user_rate"` Season *int32 `json:"season"`
ReviewID *int64 `json:"review_id"` EpisodesAired *int32 `json:"episodes_aired"`
UserCtime pgtype.Timestamptz `json:"user_ctime"` EpisodesAll *int32 `json:"episodes_all"`
TitleStorageType string `json:"title_storage_type"` EpisodesLen []byte `json:"episodes_len"`
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"`
} }
// 100 is default limit // 100 is default limit
func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) {
rows, err := q.db.Query(ctx, searchUserTitles, rows, err := q.db.Query(ctx, searchUserTitles,
arg.Forward,
arg.SortBy,
arg.CursorYear,
arg.CursorID,
arg.CursorRating,
arg.Word, arg.Word,
arg.Ongoing, arg.Status,
arg.Planned,
arg.Dropped,
arg.InProgress,
arg.Finished,
arg.Rate,
arg.Rating, arg.Rating,
arg.ReleaseYear, arg.ReleaseYear,
arg.ReleaseSeason, arg.ReleaseSeason,
arg.UsertitleStatus,
arg.Limit, arg.Limit,
) )
if err != nil { if err != nil {
@ -756,6 +634,12 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara
for rows.Next() { for rows.Next() {
var i SearchUserTitlesRow var i SearchUserTitlesRow
if err := rows.Scan( if err := rows.Scan(
&i.UserID,
&i.TitleID,
&i.Status,
&i.Rate,
&i.ReviewID,
&i.Ctime,
&i.ID, &i.ID,
&i.TitleNames, &i.TitleNames,
&i.StudioID, &i.StudioID,
@ -769,19 +653,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara
&i.EpisodesAired, &i.EpisodesAired,
&i.EpisodesAll, &i.EpisodesAll,
&i.EpisodesLen, &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 { ); err != nil {
return nil, err return nil, err
} }

View file

@ -14,8 +14,6 @@ sql:
emit_pointers_for_null_types: true emit_pointers_for_null_types: true
emit_empty_slices: true #slices returned by :many queries will be empty instead of nil emit_empty_slices: true #slices returned by :many queries will be empty instead of nil
overrides: overrides:
- db_type: "jsonb"
go_type: "encoding/json.RawMessage"
- db_type: "uuid" - db_type: "uuid"
nullable: false nullable: false
go_type: go_type: