Compare commits
No commits in common. "af0492cdf1101f44e1e916f9bd9faa42787040c4" and "34d9341e75dbdf36ebbf9258f82e5065fc38909c" have entirely different histories.
af0492cdf1
...
34d9341e75
57 changed files with 212 additions and 1986 deletions
|
|
@ -20,4 +20,3 @@ components:
|
||||||
$ref: "./parameters/_index.yaml"
|
$ref: "./parameters/_index.yaml"
|
||||||
schemas:
|
schemas:
|
||||||
$ref: "./schemas/_index.yaml"
|
$ref: "./schemas/_index.yaml"
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
CursorObj:
|
CursorObj:
|
||||||
$ref: "./CursorObj.yaml"
|
$ref: ./CursorObj.yaml
|
||||||
TitleSort:
|
TitleSort:
|
||||||
$ref: "./TitleSort.yaml"
|
$ref: "./TitleSort.yaml"
|
||||||
Image:
|
Image:
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -8,7 +8,7 @@ require (
|
||||||
github.com/jackc/pgx/v5 v5.7.6
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
github.com/oapi-codegen/runtime v1.1.2
|
github.com/oapi-codegen/runtime v1.1.2
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/sirupsen/logrus v1.9.3
|
golang.org/x/crypto v0.40.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -26,7 +26,6 @@ require (
|
||||||
github.com/google/uuid v1.5.0 // indirect
|
github.com/google/uuid v1.5.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|
@ -35,11 +34,11 @@ require (
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -13,71 +13,6 @@ func NewServer(db *sqlc.Queries) Server {
|
||||||
return Server{db: db}
|
return Server{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// type Cursor interface {
|
|
||||||
// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error)
|
|
||||||
|
|
||||||
// Values() map[string]interface{}
|
|
||||||
// // for logs only
|
|
||||||
// Type() string
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type CursorByID struct {
|
|
||||||
// ID int64
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) {
|
|
||||||
// var cur CursorByID
|
|
||||||
// if err := json.Unmarshal(data, &cur); err != nil {
|
|
||||||
// return nil, fmt.Errorf("invalid cursor (id): %w", err)
|
|
||||||
// }
|
|
||||||
// if cur.ID == 0 {
|
|
||||||
// return nil, errors.New("cursor id must be non-zero")
|
|
||||||
// }
|
|
||||||
// return cur, nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (c CursorByID) Values() map[string]interface{} {
|
|
||||||
// return map[string]interface{}{
|
|
||||||
// "cursor_id": c.ID,
|
|
||||||
// "cursor_year": nil,
|
|
||||||
// "cursor_rating": nil,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (c CursorByID) Type() string { return "id" }
|
|
||||||
|
|
||||||
// func NewCursor(sortBy string) (Cursor, error) {
|
|
||||||
// switch Type(sortBy) {
|
|
||||||
// case TypeID:
|
|
||||||
// return CursorByID{}, nil
|
|
||||||
// case TypeYear:
|
|
||||||
// return CursorByYear{}, nil
|
|
||||||
// case TypeRating:
|
|
||||||
// return CursorByRating{}, nil
|
|
||||||
// default:
|
|
||||||
// return nil, fmt.Errorf("unsupported sort_by: %q", sortBy)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// decodes a base64-encoded JSON string into a CursorObj
|
|
||||||
// Returns the parsed CursorObj and an error
|
|
||||||
// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) {
|
|
||||||
|
|
||||||
// // Decode base64
|
|
||||||
// decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, fmt.Errorf("parseCursor: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Parse JSON
|
|
||||||
// var cursor oapi.CursorObj
|
|
||||||
// if err := json.Unmarshal(decoded, &cursor); err != nil {
|
|
||||||
// return nil, fmt.Errorf("parseCursor: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return &cursor, nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
func parseInt64(s string) (int32, error) {
|
func parseInt64(s string) (int32, error) {
|
||||||
i, err := strconv.ParseInt(s, 10, 64)
|
i, err := strconv.ParseInt(s, 10, 64)
|
||||||
return int32(i), err
|
return int32(i), err
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseCursorInto parses an opaque base64 cursor and injects values into target struct.
|
|
||||||
//
|
|
||||||
// Supported sort types:
|
|
||||||
// - "id" → sets CursorID (must be *int64)
|
|
||||||
// - "year" → sets CursorID (*int64) + CursorYear (*int32)
|
|
||||||
// - "rating" → sets CursorID (*int64) + CursorRating (*float64)
|
|
||||||
//
|
|
||||||
// Target struct may have any subset of these fields (e.g. only CursorID).
|
|
||||||
// Unknown fields are ignored. Missing fields → values are dropped (safe).
|
|
||||||
//
|
|
||||||
// Returns error if cursor is invalid or inconsistent with sort_by.
|
|
||||||
func ParseCursorInto(sortBy, cursorStr string, target any) error {
|
|
||||||
if cursorStr == "" {
|
|
||||||
return nil // no cursor → nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Decode cursor
|
|
||||||
payload, err := decodeCursor(cursorStr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Extract ID (required for all types)
|
|
||||||
id, err := extractInt64(payload, "id")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cursor: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get reflect value of target (must be ptr to struct)
|
|
||||||
v := reflect.ValueOf(target)
|
|
||||||
if v.Kind() != reflect.Ptr || v.IsNil() {
|
|
||||||
return fmt.Errorf("target must be non-nil pointer to struct")
|
|
||||||
}
|
|
||||||
v = v.Elem()
|
|
||||||
if v.Kind() != reflect.Struct {
|
|
||||||
return fmt.Errorf("target must be pointer to struct")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Helper: set field if exists and compatible
|
|
||||||
setField := func(fieldName string, value any) {
|
|
||||||
f := v.FieldByName(fieldName)
|
|
||||||
if !f.IsValid() || !f.CanSet() {
|
|
||||||
return // field not found or unexported
|
|
||||||
}
|
|
||||||
ft := f.Type()
|
|
||||||
vv := reflect.ValueOf(value)
|
|
||||||
|
|
||||||
// Case: field is *T, value is T → wrap in pointer
|
|
||||||
if ft.Kind() == reflect.Ptr {
|
|
||||||
elemType := ft.Elem()
|
|
||||||
if vv.Type().AssignableTo(elemType) {
|
|
||||||
ptr := reflect.New(elemType)
|
|
||||||
ptr.Elem().Set(vv)
|
|
||||||
f.Set(ptr)
|
|
||||||
}
|
|
||||||
// nil → leave as zero (nil pointer)
|
|
||||||
} else if vv.Type().AssignableTo(ft) {
|
|
||||||
f.Set(vv)
|
|
||||||
}
|
|
||||||
// else: type mismatch → silently skip (safe)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Dispatch by sort type
|
|
||||||
switch sortBy {
|
|
||||||
case "id":
|
|
||||||
setField("CursorID", id)
|
|
||||||
|
|
||||||
case "year":
|
|
||||||
setField("CursorID", id)
|
|
||||||
param, err := extractString(payload, "param")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cursor year: %w", err)
|
|
||||||
}
|
|
||||||
year, err := strconv.Atoi(param)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cursor year: param must be integer, got %q", param)
|
|
||||||
}
|
|
||||||
setField("CursorYear", int32(year)) // or int, depending on your schema
|
|
||||||
|
|
||||||
case "rating":
|
|
||||||
setField("CursorID", id)
|
|
||||||
param, err := extractString(payload, "param")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cursor rating: %w", err)
|
|
||||||
}
|
|
||||||
rating, err := strconv.ParseFloat(param, 64)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cursor rating: param must be float, got %q", param)
|
|
||||||
}
|
|
||||||
setField("CursorRating", rating)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported sort_by: %q", sortBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
|
||||||
func decodeCursor(cursorStr string) (map[string]any, error) {
|
|
||||||
data, err := base64.RawURLEncoding.DecodeString(cursorStr)
|
|
||||||
if err != nil {
|
|
||||||
data, err = base64.StdEncoding.DecodeString(cursorStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid base64 cursor")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var m map[string]any
|
|
||||||
if err := json.Unmarshal(data, &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid cursor JSON: %w", err)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractInt64(m map[string]any, key string) (int64, error) {
|
|
||||||
v, ok := m[key]
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("missing %q", key)
|
|
||||||
}
|
|
||||||
switch x := v.(type) {
|
|
||||||
case float64:
|
|
||||||
if x == float64(int64(x)) {
|
|
||||||
return int64(x), nil
|
|
||||||
}
|
|
||||||
case string:
|
|
||||||
i, err := strconv.ParseInt(x, 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
return i, nil
|
|
||||||
}
|
|
||||||
case int64, int, int32:
|
|
||||||
return reflect.ValueOf(x).Int(), nil
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("%q must be integer", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractString(m map[string]interface{}, key string) (string, error) {
|
|
||||||
v, ok := m[key]
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("missing %q", key)
|
|
||||||
}
|
|
||||||
s, ok := v.(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("%q must be string", key)
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
@ -218,17 +218,9 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl
|
||||||
|
|
||||||
func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) {
|
func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) {
|
||||||
opai_titles := make([]oapi.Title, 0)
|
opai_titles := make([]oapi.Title, 0)
|
||||||
|
cursor := oapi.CursorObj{
|
||||||
// old_cursor := oapi.CursorObj{
|
Id: 1,
|
||||||
// Id: 1,
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// if request.Params.Cursor != nil {
|
|
||||||
// if old_cursor, err := parseCursor(*request.Params.Cursor); err != nil {
|
|
||||||
// log.Errorf("%v", err)
|
|
||||||
// return oapi.GetTitles400Response{}, err
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
word := Word2Sqlc(request.Params.Word)
|
word := Word2Sqlc(request.Params.Word)
|
||||||
status, err := TitleStatus2Sqlc(request.Params.Status)
|
status, err := TitleStatus2Sqlc(request.Params.Status)
|
||||||
|
|
@ -241,29 +233,15 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
|
||||||
log.Errorf("%v", err)
|
log.Errorf("%v", err)
|
||||||
return oapi.GetTitles400Response{}, err
|
return oapi.GetTitles400Response{}, err
|
||||||
}
|
}
|
||||||
|
// param = nil means it will not be used
|
||||||
params := sqlc.SearchTitlesParams{
|
titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{
|
||||||
Word: word,
|
Word: word,
|
||||||
Status: status,
|
Status: status,
|
||||||
Rating: request.Params.Rating,
|
Rating: request.Params.Rating,
|
||||||
ReleaseYear: request.Params.ReleaseYear,
|
ReleaseYear: request.Params.ReleaseYear,
|
||||||
ReleaseSeason: season,
|
ReleaseSeason: season,
|
||||||
Forward: true,
|
Limit: request.Params.Limit,
|
||||||
// SortBy: "id",
|
})
|
||||||
Limit: request.Params.Limit,
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Params.Sort != nil {
|
|
||||||
if request.Params.Cursor != nil {
|
|
||||||
err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("%v", err)
|
|
||||||
return oapi.GetTitles400Response{}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// param = nil means it will not be used
|
|
||||||
titles, err := s.db.SearchTitles(ctx, params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("%v", err)
|
log.Errorf("%v", err)
|
||||||
return oapi.GetTitles500Response{}, nil
|
return oapi.GetTitles500Response{}, nil
|
||||||
|
|
@ -282,5 +260,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
|
||||||
opai_titles = append(opai_titles, t)
|
opai_titles = append(opai_titles, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
return oapi.GetTitles200JSONResponse{Cursor: oapi.CursorObj{}, Data: opai_titles}, nil
|
return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -31,17 +31,17 @@ func main() {
|
||||||
// log.Fatalf("Failed to init config: %v\n", err)
|
// log.Fatalf("Failed to init config: %v\n", err)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
|
conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer pool.Close()
|
defer conn.Close(context.Background())
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
queries := sqlc.New(pool)
|
queries := sqlc.New(conn)
|
||||||
|
|
||||||
server := handlers.NewServer(queries)
|
server := handlers.NewServer(queries)
|
||||||
// r.LoadHTMLGlob("templates/*")
|
// r.LoadHTMLGlob("templates/*")
|
||||||
|
|
|
||||||
|
|
@ -81,96 +81,58 @@ WHERE id = sqlc.arg('title_id')::bigint;
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
FROM titles
|
FROM titles
|
||||||
WHERE
|
WHERE
|
||||||
CASE
|
CASE
|
||||||
WHEN sqlc.arg('forward')::boolean THEN
|
WHEN sqlc.narg('word')::text IS NOT NULL THEN
|
||||||
-- forward: greater than cursor (next page)
|
(
|
||||||
CASE sqlc.arg('sort_by')::text
|
SELECT bool_and(
|
||||||
WHEN 'year' THEN
|
EXISTS (
|
||||||
(sqlc.narg('cursor_year')::int IS NULL) OR
|
SELECT 1
|
||||||
(release_year > sqlc.narg('cursor_year')::int) OR
|
FROM jsonb_each_text(title_names) AS t(key, val)
|
||||||
(release_year = sqlc.narg('cursor_year')::int AND id > sqlc.narg('cursor_id')::bigint)
|
WHERE val ILIKE pattern
|
||||||
|
|
||||||
WHEN 'rating' THEN
|
|
||||||
(sqlc.narg('cursor_rating')::float IS NULL) OR
|
|
||||||
(rating > sqlc.narg('cursor_rating')::float) OR
|
|
||||||
(rating = sqlc.narg('cursor_rating')::float AND id > sqlc.narg('cursor_id')::bigint)
|
|
||||||
|
|
||||||
WHEN 'id' THEN
|
|
||||||
(sqlc.narg('cursor_id')::bigint IS NULL) OR
|
|
||||||
(id > sqlc.narg('cursor_id')::bigint)
|
|
||||||
|
|
||||||
ELSE true -- fallback
|
|
||||||
END
|
|
||||||
|
|
||||||
ELSE
|
|
||||||
-- backward: less than cursor (prev page)
|
|
||||||
CASE sqlc.arg('sort_by')::text
|
|
||||||
WHEN 'year' THEN
|
|
||||||
(sqlc.narg('cursor_year')::int IS NULL) OR
|
|
||||||
(release_year < sqlc.narg('cursor_year')::int) OR
|
|
||||||
(release_year = sqlc.narg('cursor_year')::int AND id < sqlc.narg('cursor_id')::bigint)
|
|
||||||
|
|
||||||
WHEN 'rating' THEN
|
|
||||||
(sqlc.narg('cursor_rating')::float IS NULL) OR
|
|
||||||
(rating < sqlc.narg('cursor_rating')::float) OR
|
|
||||||
(rating = sqlc.narg('cursor_rating')::float AND id < sqlc.narg('cursor_id')::bigint)
|
|
||||||
|
|
||||||
WHEN 'id' THEN
|
|
||||||
(sqlc.narg('cursor_id')::bigint IS NULL) OR
|
|
||||||
(id < sqlc.narg('cursor_id')::bigint)
|
|
||||||
|
|
||||||
ELSE true
|
|
||||||
END
|
|
||||||
END
|
|
||||||
|
|
||||||
AND (
|
|
||||||
CASE
|
|
||||||
WHEN sqlc.narg('word')::text IS NOT NULL THEN
|
|
||||||
(
|
|
||||||
SELECT bool_and(
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM jsonb_each_text(title_names) AS t(key, val)
|
|
||||||
WHERE val ILIKE pattern
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
FROM unnest(
|
|
||||||
ARRAY(
|
|
||||||
SELECT '%' || trim(w) || '%'
|
|
||||||
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
|
|
||||||
WHERE trim(w) <> ''
|
|
||||||
)
|
|
||||||
) AS pattern
|
|
||||||
)
|
)
|
||||||
ELSE true
|
FROM unnest(
|
||||||
END
|
ARRAY(
|
||||||
)
|
SELECT '%' || trim(w) || '%'
|
||||||
|
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
|
||||||
|
WHERE trim(w) <> ''
|
||||||
|
)
|
||||||
|
) AS pattern
|
||||||
|
)
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
|
||||||
AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t)
|
AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t)
|
||||||
AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float)
|
AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float)
|
||||||
AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int)
|
AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int)
|
||||||
AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t)
|
AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t)
|
||||||
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN sqlc.arg('forward')::boolean THEN
|
-- Основной ключ: выбранное поле
|
||||||
CASE
|
CASE
|
||||||
WHEN sqlc.arg('sort_by')::text = 'id' THEN id
|
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
|
||||||
WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year
|
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name
|
||||||
WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating
|
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
|
||||||
END
|
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
|
||||||
END ASC,
|
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
|
||||||
CASE WHEN NOT sqlc.arg('forward')::boolean THEN
|
END ASC,
|
||||||
CASE
|
CASE
|
||||||
WHEN sqlc.arg('sort_by')::text = 'id' THEN id
|
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
|
||||||
WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year
|
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name
|
||||||
WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating
|
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
|
||||||
END
|
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
|
||||||
END DESC,
|
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
|
||||||
|
END DESC,
|
||||||
CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN id END ASC
|
|
||||||
|
|
||||||
|
-- Вторичный ключ: id — только если НЕ сортируем по id
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id
|
||||||
|
END ASC,
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id
|
||||||
|
END DESC
|
||||||
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
|
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
|
||||||
|
-- OFFSET sqlc.narg('offset')::int;
|
||||||
|
|
||||||
-- name: SearchUserTitles :many
|
-- name: SearchUserTitles :many
|
||||||
SELECT
|
SELECT
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,5 @@
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.45.0",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20.x"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import React from "react";
|
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 "./components/UserPage/UserPage";
|
||||||
import TitlesPage from "./pages/TitlesPage/TitlesPage";
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/users/:id" element={<UserPage />} />
|
<Route path="/users/:id" element={<UserPage />} />
|
||||||
<Route path="/titles" element={<TitlesPage />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,10 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise';
|
||||||
export { OpenAPI } from './core/OpenAPI';
|
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 { Image } from './models/Image';
|
|
||||||
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 { Tag } from './models/Tag';
|
export type { Tag } from './models/Tag';
|
||||||
export type { Tags } from './models/Tags';
|
|
||||||
export type { Title } from './models/Title';
|
export type { Title } from './models/Title';
|
||||||
export { 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 { UserTitleStatus } from './models/UserTitleStatus';
|
|
||||||
|
|
||||||
export { DefaultService } from './services/DefaultService';
|
export { DefaultService } from './services/DefaultService';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type Image = {
|
|
||||||
id?: number;
|
|
||||||
storage_type?: string;
|
|
||||||
image_path?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* Title release season
|
|
||||||
*/
|
|
||||||
export enum ReleaseSeason {
|
|
||||||
WINTER = 'winter',
|
|
||||||
SPRING = 'spring',
|
|
||||||
SUMMER = 'summer',
|
|
||||||
FALL = 'fall',
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import type { Image } from './Image';
|
|
||||||
export type Studio = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
poster?: Image;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -2,7 +2,4 @@
|
||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/**
|
export type Tag = Record<string, any>;
|
||||||
* A localized tag: keys are language codes (ISO 639-1), values are tag names
|
|
||||||
*/
|
|
||||||
export type Tag = Record<string, string>;
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import type { Tag } from './Tag';
|
|
||||||
/**
|
|
||||||
* Array of localized tags
|
|
||||||
*/
|
|
||||||
export type Tags = Array<Tag>;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* Title status
|
|
||||||
*/
|
|
||||||
export enum TitleStatus {
|
|
||||||
FINISHED = 'finished',
|
|
||||||
ONGOING = 'ongoing',
|
|
||||||
PLANNED = 'planned',
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|
@ -30,6 +30,6 @@ export type User = {
|
||||||
/**
|
/**
|
||||||
* Timestamp when the user was created
|
* Timestamp when the user was created
|
||||||
*/
|
*/
|
||||||
creation_date?: string;
|
creation_date: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* User's title status
|
|
||||||
*/
|
|
||||||
export enum UserTitleStatus {
|
|
||||||
FINISHED = 'finished',
|
|
||||||
PLANNED = 'planned',
|
|
||||||
DROPPED = 'dropped',
|
|
||||||
IN_PROGRESS = 'in-progress',
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type cursor = string;
|
|
||||||
|
|
@ -2,84 +2,11 @@
|
||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-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 { User } from '../models/User';
|
||||||
import type { UserTitle } from '../models/UserTitle';
|
|
||||||
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
|
|
||||||
* @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
|
* Get user info
|
||||||
* @param userId
|
* @param userId
|
||||||
|
|
@ -101,47 +28,7 @@ export class DefaultService {
|
||||||
'fields': fields,
|
'fields': fields,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
400: `Request params are not correct`,
|
|
||||||
404: `User not found`,
|
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`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
|
||||||
import type { ApiResult } from './ApiResult';
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
public readonly url: string;
|
|
||||||
public readonly status: number;
|
|
||||||
public readonly statusText: string;
|
|
||||||
public readonly body: any;
|
|
||||||
public readonly request: ApiRequestOptions;
|
|
||||||
|
|
||||||
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
|
|
||||||
super(message);
|
|
||||||
|
|
||||||
this.name = 'ApiError';
|
|
||||||
this.url = response.url;
|
|
||||||
this.status = response.status;
|
|
||||||
this.statusText = response.statusText;
|
|
||||||
this.body = response.body;
|
|
||||||
this.request = request;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type ApiRequestOptions = {
|
|
||||||
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
|
|
||||||
readonly url: string;
|
|
||||||
readonly path?: Record<string, any>;
|
|
||||||
readonly cookies?: Record<string, any>;
|
|
||||||
readonly headers?: Record<string, any>;
|
|
||||||
readonly query?: Record<string, any>;
|
|
||||||
readonly formData?: Record<string, any>;
|
|
||||||
readonly body?: any;
|
|
||||||
readonly mediaType?: string;
|
|
||||||
readonly responseHeader?: string;
|
|
||||||
readonly errors?: Record<number, string>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type ApiResult = {
|
|
||||||
readonly url: string;
|
|
||||||
readonly ok: boolean;
|
|
||||||
readonly status: number;
|
|
||||||
readonly statusText: string;
|
|
||||||
readonly body: any;
|
|
||||||
};
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export class CancelError extends Error {
|
|
||||||
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'CancelError';
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isCancelled(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnCancel {
|
|
||||||
readonly isResolved: boolean;
|
|
||||||
readonly isRejected: boolean;
|
|
||||||
readonly isCancelled: boolean;
|
|
||||||
|
|
||||||
(cancelHandler: () => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CancelablePromise<T> implements Promise<T> {
|
|
||||||
#isResolved: boolean;
|
|
||||||
#isRejected: boolean;
|
|
||||||
#isCancelled: boolean;
|
|
||||||
readonly #cancelHandlers: (() => void)[];
|
|
||||||
readonly #promise: Promise<T>;
|
|
||||||
#resolve?: (value: T | PromiseLike<T>) => void;
|
|
||||||
#reject?: (reason?: any) => void;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
executor: (
|
|
||||||
resolve: (value: T | PromiseLike<T>) => void,
|
|
||||||
reject: (reason?: any) => void,
|
|
||||||
onCancel: OnCancel
|
|
||||||
) => void
|
|
||||||
) {
|
|
||||||
this.#isResolved = false;
|
|
||||||
this.#isRejected = false;
|
|
||||||
this.#isCancelled = false;
|
|
||||||
this.#cancelHandlers = [];
|
|
||||||
this.#promise = new Promise<T>((resolve, reject) => {
|
|
||||||
this.#resolve = resolve;
|
|
||||||
this.#reject = reject;
|
|
||||||
|
|
||||||
const onResolve = (value: T | PromiseLike<T>): void => {
|
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#isResolved = true;
|
|
||||||
if (this.#resolve) this.#resolve(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onReject = (reason?: any): void => {
|
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#isRejected = true;
|
|
||||||
if (this.#reject) this.#reject(reason);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = (cancelHandler: () => void): void => {
|
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#cancelHandlers.push(cancelHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isResolved', {
|
|
||||||
get: (): boolean => this.#isResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isRejected', {
|
|
||||||
get: (): boolean => this.#isRejected,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isCancelled', {
|
|
||||||
get: (): boolean => this.#isCancelled,
|
|
||||||
});
|
|
||||||
|
|
||||||
return executor(onResolve, onReject, onCancel as OnCancel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get [Symbol.toStringTag]() {
|
|
||||||
return "Cancellable Promise";
|
|
||||||
}
|
|
||||||
|
|
||||||
public then<TResult1 = T, TResult2 = never>(
|
|
||||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
|
||||||
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
|
||||||
): Promise<TResult1 | TResult2> {
|
|
||||||
return this.#promise.then(onFulfilled, onRejected);
|
|
||||||
}
|
|
||||||
|
|
||||||
public catch<TResult = never>(
|
|
||||||
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
|
|
||||||
): Promise<T | TResult> {
|
|
||||||
return this.#promise.catch(onRejected);
|
|
||||||
}
|
|
||||||
|
|
||||||
public finally(onFinally?: (() => void) | null): Promise<T> {
|
|
||||||
return this.#promise.finally(onFinally);
|
|
||||||
}
|
|
||||||
|
|
||||||
public cancel(): void {
|
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#isCancelled = true;
|
|
||||||
if (this.#cancelHandlers.length) {
|
|
||||||
try {
|
|
||||||
for (const cancelHandler of this.#cancelHandlers) {
|
|
||||||
cancelHandler();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Cancellation threw an error', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.#cancelHandlers.length = 0;
|
|
||||||
if (this.#reject) this.#reject(new CancelError('Request aborted'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isCancelled(): boolean {
|
|
||||||
return this.#isCancelled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
|
||||||
|
|
||||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
|
||||||
type Headers = Record<string, string>;
|
|
||||||
|
|
||||||
export type OpenAPIConfig = {
|
|
||||||
BASE: string;
|
|
||||||
VERSION: string;
|
|
||||||
WITH_CREDENTIALS: boolean;
|
|
||||||
CREDENTIALS: 'include' | 'omit' | 'same-origin';
|
|
||||||
TOKEN?: string | Resolver<string> | undefined;
|
|
||||||
USERNAME?: string | Resolver<string> | undefined;
|
|
||||||
PASSWORD?: string | Resolver<string> | undefined;
|
|
||||||
HEADERS?: Headers | Resolver<Headers> | undefined;
|
|
||||||
ENCODE_PATH?: ((path: string) => string) | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OpenAPI: OpenAPIConfig = {
|
|
||||||
BASE: '/api/v1',
|
|
||||||
VERSION: '1.0.0',
|
|
||||||
WITH_CREDENTIALS: false,
|
|
||||||
CREDENTIALS: 'include',
|
|
||||||
TOKEN: undefined,
|
|
||||||
USERNAME: undefined,
|
|
||||||
PASSWORD: undefined,
|
|
||||||
HEADERS: undefined,
|
|
||||||
ENCODE_PATH: undefined,
|
|
||||||
};
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import axios from 'axios';
|
|
||||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
|
|
||||||
import FormData from 'form-data';
|
|
||||||
|
|
||||||
import { ApiError } from './ApiError';
|
|
||||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
|
||||||
import type { ApiResult } from './ApiResult';
|
|
||||||
import { CancelablePromise } from './CancelablePromise';
|
|
||||||
import type { OnCancel } from './CancelablePromise';
|
|
||||||
import type { OpenAPIConfig } from './OpenAPI';
|
|
||||||
|
|
||||||
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => {
|
|
||||||
return value !== undefined && value !== null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isString = (value: any): value is string => {
|
|
||||||
return typeof value === 'string';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isStringWithValue = (value: any): value is string => {
|
|
||||||
return isString(value) && value !== '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isBlob = (value: any): value is Blob => {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
typeof value.type === 'string' &&
|
|
||||||
typeof value.stream === 'function' &&
|
|
||||||
typeof value.arrayBuffer === 'function' &&
|
|
||||||
typeof value.constructor === 'function' &&
|
|
||||||
typeof value.constructor.name === 'string' &&
|
|
||||||
/^(Blob|File)$/.test(value.constructor.name) &&
|
|
||||||
/^(Blob|File)$/.test(value[Symbol.toStringTag])
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isFormData = (value: any): value is FormData => {
|
|
||||||
return value instanceof FormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isSuccess = (status: number): boolean => {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const base64 = (str: string): string => {
|
|
||||||
try {
|
|
||||||
return btoa(str);
|
|
||||||
} catch (err) {
|
|
||||||
// @ts-ignore
|
|
||||||
return Buffer.from(str).toString('base64');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getQueryString = (params: Record<string, any>): string => {
|
|
||||||
const qs: string[] = [];
|
|
||||||
|
|
||||||
const append = (key: string, value: any) => {
|
|
||||||
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const process = (key: string, value: any) => {
|
|
||||||
if (isDefined(value)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(v => {
|
|
||||||
process(key, v);
|
|
||||||
});
|
|
||||||
} else if (typeof value === 'object') {
|
|
||||||
Object.entries(value).forEach(([k, v]) => {
|
|
||||||
process(`${key}[${k}]`, v);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
process(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (qs.length > 0) {
|
|
||||||
return `?${qs.join('&')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
|
||||||
const encoder = config.ENCODE_PATH || encodeURI;
|
|
||||||
|
|
||||||
const path = options.url
|
|
||||||
.replace('{api-version}', config.VERSION)
|
|
||||||
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
|
||||||
if (options.path?.hasOwnProperty(group)) {
|
|
||||||
return encoder(String(options.path[group]));
|
|
||||||
}
|
|
||||||
return substring;
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = `${config.BASE}${path}`;
|
|
||||||
if (options.query) {
|
|
||||||
return `${url}${getQueryString(options.query)}`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
|
|
||||||
if (options.formData) {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
const process = (key: string, value: any) => {
|
|
||||||
if (isString(value) || isBlob(value)) {
|
|
||||||
formData.append(key, value);
|
|
||||||
} else {
|
|
||||||
formData.append(key, JSON.stringify(value));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(options.formData)
|
|
||||||
.filter(([_, value]) => isDefined(value))
|
|
||||||
.forEach(([key, value]) => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(v => process(key, v));
|
|
||||||
} else {
|
|
||||||
process(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
|
||||||
|
|
||||||
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => {
|
|
||||||
if (typeof resolver === 'function') {
|
|
||||||
return (resolver as Resolver<T>)(options);
|
|
||||||
}
|
|
||||||
return resolver;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => {
|
|
||||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
|
||||||
resolve(options, config.TOKEN),
|
|
||||||
resolve(options, config.USERNAME),
|
|
||||||
resolve(options, config.PASSWORD),
|
|
||||||
resolve(options, config.HEADERS),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {}
|
|
||||||
|
|
||||||
const headers = Object.entries({
|
|
||||||
Accept: 'application/json',
|
|
||||||
...additionalHeaders,
|
|
||||||
...options.headers,
|
|
||||||
...formHeaders,
|
|
||||||
})
|
|
||||||
.filter(([_, value]) => isDefined(value))
|
|
||||||
.reduce((headers, [key, value]) => ({
|
|
||||||
...headers,
|
|
||||||
[key]: String(value),
|
|
||||||
}), {} as Record<string, string>);
|
|
||||||
|
|
||||||
if (isStringWithValue(token)) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStringWithValue(username) && isStringWithValue(password)) {
|
|
||||||
const credentials = base64(`${username}:${password}`);
|
|
||||||
headers['Authorization'] = `Basic ${credentials}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.body !== undefined) {
|
|
||||||
if (options.mediaType) {
|
|
||||||
headers['Content-Type'] = options.mediaType;
|
|
||||||
} else if (isBlob(options.body)) {
|
|
||||||
headers['Content-Type'] = options.body.type || 'application/octet-stream';
|
|
||||||
} else if (isString(options.body)) {
|
|
||||||
headers['Content-Type'] = 'text/plain';
|
|
||||||
} else if (!isFormData(options.body)) {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRequestBody = (options: ApiRequestOptions): any => {
|
|
||||||
if (options.body) {
|
|
||||||
return options.body;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendRequest = async <T>(
|
|
||||||
config: OpenAPIConfig,
|
|
||||||
options: ApiRequestOptions,
|
|
||||||
url: string,
|
|
||||||
body: any,
|
|
||||||
formData: FormData | undefined,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
onCancel: OnCancel,
|
|
||||||
axiosClient: AxiosInstance
|
|
||||||
): Promise<AxiosResponse<T>> => {
|
|
||||||
const source = axios.CancelToken.source();
|
|
||||||
|
|
||||||
const requestConfig: AxiosRequestConfig = {
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
data: body ?? formData,
|
|
||||||
method: options.method,
|
|
||||||
withCredentials: config.WITH_CREDENTIALS,
|
|
||||||
withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false,
|
|
||||||
cancelToken: source.token,
|
|
||||||
};
|
|
||||||
|
|
||||||
onCancel(() => source.cancel('The user aborted a request.'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await axiosClient.request(requestConfig);
|
|
||||||
} catch (error) {
|
|
||||||
const axiosError = error as AxiosError<T>;
|
|
||||||
if (axiosError.response) {
|
|
||||||
return axiosError.response;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => {
|
|
||||||
if (responseHeader) {
|
|
||||||
const content = response.headers[responseHeader];
|
|
||||||
if (isString(content)) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getResponseBody = (response: AxiosResponse<any>): any => {
|
|
||||||
if (response.status !== 204) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
|
|
||||||
const errors: Record<number, string> = {
|
|
||||||
400: 'Bad Request',
|
|
||||||
401: 'Unauthorized',
|
|
||||||
403: 'Forbidden',
|
|
||||||
404: 'Not Found',
|
|
||||||
500: 'Internal Server Error',
|
|
||||||
502: 'Bad Gateway',
|
|
||||||
503: 'Service Unavailable',
|
|
||||||
...options.errors,
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = errors[result.status];
|
|
||||||
if (error) {
|
|
||||||
throw new ApiError(options, result, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
const errorStatus = result.status ?? 'unknown';
|
|
||||||
const errorStatusText = result.statusText ?? 'unknown';
|
|
||||||
const errorBody = (() => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(result.body, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
throw new ApiError(options, result,
|
|
||||||
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request method
|
|
||||||
* @param config The OpenAPI configuration object
|
|
||||||
* @param options The request options from the service
|
|
||||||
* @param axiosClient The axios client instance to use
|
|
||||||
* @returns CancelablePromise<T>
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => {
|
|
||||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
|
||||||
try {
|
|
||||||
const url = getUrl(config, options);
|
|
||||||
const formData = getFormData(options);
|
|
||||||
const body = getRequestBody(options);
|
|
||||||
const headers = await getHeaders(config, options, formData);
|
|
||||||
|
|
||||||
if (!onCancel.isCancelled) {
|
|
||||||
const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient);
|
|
||||||
const responseBody = getResponseBody(response);
|
|
||||||
const responseHeader = getResponseHeader(response, options.responseHeader);
|
|
||||||
|
|
||||||
const result: ApiResult = {
|
|
||||||
url,
|
|
||||||
ok: isSuccess(response.status),
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
body: responseHeader ?? responseBody,
|
|
||||||
};
|
|
||||||
|
|
||||||
catchErrorCodes(options, result);
|
|
||||||
|
|
||||||
resolve(result.body);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export { ApiError } from './core/ApiError';
|
|
||||||
export { CancelablePromise, CancelError } from './core/CancelablePromise';
|
|
||||||
export { OpenAPI } from './core/OpenAPI';
|
|
||||||
export type { OpenAPIConfig } from './core/OpenAPI';
|
|
||||||
|
|
||||||
export type { cursor } from './models/cursor';
|
|
||||||
export type { Image } from './models/Image';
|
|
||||||
export { ReleaseSeason } from './models/ReleaseSeason';
|
|
||||||
export type { Review } from './models/Review';
|
|
||||||
export type { Studio } from './models/Studio';
|
|
||||||
export type { Tag } from './models/Tag';
|
|
||||||
export type { Tags } from './models/Tags';
|
|
||||||
export type { Title } from './models/Title';
|
|
||||||
export { TitleStatus } from './models/TitleStatus';
|
|
||||||
export type { User } from './models/User';
|
|
||||||
export type { UserTitle } from './models/UserTitle';
|
|
||||||
export { UserTitleStatus } from './models/UserTitleStatus';
|
|
||||||
|
|
||||||
export { DefaultService } from './services/DefaultService';
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type Image = {
|
|
||||||
id?: number;
|
|
||||||
storage_type?: string;
|
|
||||||
image_path?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* Title release season
|
|
||||||
*/
|
|
||||||
export enum ReleaseSeason {
|
|
||||||
WINTER = 'winter',
|
|
||||||
SPRING = 'spring',
|
|
||||||
SUMMER = 'summer',
|
|
||||||
FALL = 'fall',
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type Review = Record<string, any>;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import type { Image } from './Image';
|
|
||||||
export type Studio = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
poster?: Image;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* A localized tag: keys are language codes (ISO 639-1), values are tag names
|
|
||||||
*/
|
|
||||||
export type Tag = Record<string, string>;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import type { Tag } from './Tag';
|
|
||||||
/**
|
|
||||||
* Array of localized tags
|
|
||||||
*/
|
|
||||||
export type Tags = Array<Tag>;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type Title = Record<string, any>;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* Title status
|
|
||||||
*/
|
|
||||||
export enum TitleStatus {
|
|
||||||
FINISHED = 'finished',
|
|
||||||
ONGOING = 'ongoing',
|
|
||||||
PLANNED = 'planned',
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type User = {
|
|
||||||
/**
|
|
||||||
* Unique user ID (primary key)
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
/**
|
|
||||||
* ID of the user avatar (references images table)
|
|
||||||
*/
|
|
||||||
avatar_id?: number | null;
|
|
||||||
/**
|
|
||||||
* User email
|
|
||||||
*/
|
|
||||||
mail?: string;
|
|
||||||
/**
|
|
||||||
* Username (alphanumeric + _ or -)
|
|
||||||
*/
|
|
||||||
nickname: string;
|
|
||||||
/**
|
|
||||||
* Display name
|
|
||||||
*/
|
|
||||||
disp_name?: string;
|
|
||||||
/**
|
|
||||||
* User description
|
|
||||||
*/
|
|
||||||
user_desc?: string;
|
|
||||||
/**
|
|
||||||
* Timestamp when the user was created
|
|
||||||
*/
|
|
||||||
creation_date?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type UserTitle = Record<string, any>;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* User's title status
|
|
||||||
*/
|
|
||||||
export enum UserTitleStatus {
|
|
||||||
FINISHED = 'finished',
|
|
||||||
PLANNED = 'planned',
|
|
||||||
DROPPED = 'dropped',
|
|
||||||
IN_PROGRESS = 'in-progress',
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
export type cursor = string;
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
/* generated using openapi-typescript-codegen -- do not edit */
|
|
||||||
/* istanbul ignore file */
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
import type { ReleaseSeason } from '../models/ReleaseSeason';
|
|
||||||
import type { Title } from '../models/Title';
|
|
||||||
import type { TitleStatus } from '../models/TitleStatus';
|
|
||||||
import type { User } from '../models/User';
|
|
||||||
import type { UserTitle } from '../models/UserTitle';
|
|
||||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
|
||||||
import { OpenAPI } from '../core/OpenAPI';
|
|
||||||
import { request as __request } from '../core/request';
|
|
||||||
export class DefaultService {
|
|
||||||
/**
|
|
||||||
* Get titles
|
|
||||||
* @param word
|
|
||||||
* @param status
|
|
||||||
* @param rating
|
|
||||||
* @param releaseYear
|
|
||||||
* @param releaseSeason
|
|
||||||
* @param limit
|
|
||||||
* @param offset
|
|
||||||
* @param fields
|
|
||||||
* @returns Title List of titles
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static getTitles(
|
|
||||||
word?: string,
|
|
||||||
status?: TitleStatus,
|
|
||||||
rating?: number,
|
|
||||||
releaseYear?: number,
|
|
||||||
releaseSeason?: ReleaseSeason,
|
|
||||||
limit: number = 10,
|
|
||||||
offset?: number,
|
|
||||||
fields: string = 'all',
|
|
||||||
): CancelablePromise<Array<Title>> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/titles',
|
|
||||||
query: {
|
|
||||||
'word': word,
|
|
||||||
'status': status,
|
|
||||||
'rating': rating,
|
|
||||||
'release_year': releaseYear,
|
|
||||||
'release_season': releaseSeason,
|
|
||||||
'limit': limit,
|
|
||||||
'offset': offset,
|
|
||||||
'fields': fields,
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
400: `Request params are not correct`,
|
|
||||||
500: `Unknown server error`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get title description
|
|
||||||
* @param titleId
|
|
||||||
* @param fields
|
|
||||||
* @returns Title Title description
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static getTitles1(
|
|
||||||
titleId: number,
|
|
||||||
fields: string = 'all',
|
|
||||||
): CancelablePromise<Title> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/titles/{title_id}',
|
|
||||||
path: {
|
|
||||||
'title_id': titleId,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
'fields': fields,
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
400: `Request params are not correct`,
|
|
||||||
404: `Title not found`,
|
|
||||||
500: `Unknown server error`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get user info
|
|
||||||
* @param userId
|
|
||||||
* @param fields
|
|
||||||
* @returns User User info
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static getUsers(
|
|
||||||
userId: string,
|
|
||||||
fields: string = 'all',
|
|
||||||
): CancelablePromise<User> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/users/{user_id}',
|
|
||||||
path: {
|
|
||||||
'user_id': userId,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
'fields': fields,
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
400: `Request params are not correct`,
|
|
||||||
404: `User not found`,
|
|
||||||
500: `Unknown server error`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get user titles
|
|
||||||
* @param userId
|
|
||||||
* @param cursor
|
|
||||||
* @param query
|
|
||||||
* @param limit
|
|
||||||
* @param offset
|
|
||||||
* @param fields
|
|
||||||
* @returns UserTitle List of user titles
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static getUsersTitles(
|
|
||||||
userId: string,
|
|
||||||
cursor?: string,
|
|
||||||
query?: string,
|
|
||||||
limit: number = 10,
|
|
||||||
offset?: number,
|
|
||||||
fields: string = 'all',
|
|
||||||
): CancelablePromise<Array<UserTitle>> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/users/{user_id}/titles',
|
|
||||||
path: {
|
|
||||||
'user_id': userId,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
'cursor': cursor,
|
|
||||||
'query': query,
|
|
||||||
'limit': limit,
|
|
||||||
'offset': offset,
|
|
||||||
'fields': fields,
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
400: `Request params are not correct`,
|
|
||||||
500: `Unknown server error`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface ListViewProps<TItem> {
|
|
||||||
hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>;
|
|
||||||
renderHorizontal: (item: TItem) => React.ReactNode;
|
|
||||||
renderSquare: (item: TItem) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ListView<TItem>({
|
|
||||||
hook,
|
|
||||||
renderHorizontal,
|
|
||||||
renderSquare
|
|
||||||
}: ListViewProps<TItem>) {
|
|
||||||
const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Search + Layout Switcher */}
|
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
|
||||||
<input
|
|
||||||
placeholder="Search..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button onClick={() => setViewMode("horizontal")}>Horizontal</button>
|
|
||||||
<button onClick={() => setViewMode("square")}>Square</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr",
|
|
||||||
gap: 12
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map(item =>
|
|
||||||
viewMode === "horizontal"
|
|
||||||
? renderHorizontal(item)
|
|
||||||
: renderSquare(item)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMore && (
|
|
||||||
<button onClick={loadMore} style={{ marginTop: 16 }}>
|
|
||||||
Load More
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { FetchFunction } from "../../types/list";
|
|
||||||
|
|
||||||
export function useListView<TItem>(fetchFn: FetchFunction<TItem>) {
|
|
||||||
const [items, setItems] = useState<TItem[]>([]);
|
|
||||||
const [cursor, setCursor] = useState<string | undefined>();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [viewMode, setViewMode] = useState<"horizontal" | "square">("horizontal");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadItems(true);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
const loadItems = async (reset = false) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
const result = await fetchFn({
|
|
||||||
search,
|
|
||||||
cursor: reset ? undefined : cursor,
|
|
||||||
});
|
|
||||||
|
|
||||||
setItems(prev => reset ? result.items : [...prev, ...result.items]);
|
|
||||||
setCursor(result.nextCursor);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
search,
|
|
||||||
setSearch,
|
|
||||||
viewMode,
|
|
||||||
setViewMode,
|
|
||||||
loadMore: () => loadItems(),
|
|
||||||
hasMore: Boolean(cursor),
|
|
||||||
isLoading,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
97
modules/frontend/src/components/UserPage/UserPage.module.css
Normal file
97
modules/frontend/src/components/UserPage/UserPage.module.css
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
background-color: #f5f6fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarImg {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid #4a90e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPlaceholder {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #dcdde1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #4a4a4a;
|
||||||
|
font-weight: bold;
|
||||||
|
border: 3px solid #4a90e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: #2f3640;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #718093;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #353b48;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.created {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7f8fa6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 80vh;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #4a90e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 80vh;
|
||||||
|
color: #e84118;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
@ -33,8 +33,8 @@ const UserPage: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.card}>
|
||||||
<div className={styles.avatarWrapper}>
|
<div className={styles.avatar}>
|
||||||
{user.avatar_id ? (
|
{user.avatar_id ? (
|
||||||
<img
|
<img
|
||||||
src={`/images/${user.avatar_id}.png`}
|
src={`/images/${user.avatar_id}.png`}
|
||||||
|
|
@ -48,16 +48,13 @@ const UserPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.userInfo}>
|
<div className={styles.info}>
|
||||||
<h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
|
<h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
|
||||||
<p className={styles.nickname}>@{user.nickname}</p>
|
<p className={styles.nickname}>@{user.nickname}</p>
|
||||||
{/* <p className={styles.created}>
|
|
||||||
Joined: {new Date(user.creation_date).toLocaleDateString()}
|
|
||||||
</p> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.content}>
|
|
||||||
{user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
|
{user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
|
||||||
|
<p className={styles.created}>
|
||||||
|
Joined: {new Date(user.creation_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { Title } from "../../api/models/Title";
|
|
||||||
|
|
||||||
export function TitleCardHorizontal({ title }: { title: Title }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 12,
|
|
||||||
padding: 12,
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: 8
|
|
||||||
}}>
|
|
||||||
{title.posterUrl && (
|
|
||||||
<img src={title.posterUrl} width={80} />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h3>{title.name}</h3>
|
|
||||||
<p>{title.year} · {title.season} · Rating: {title.rating}</p>
|
|
||||||
<p>Status: {title.status}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
// TitleCardSquare.tsx
|
|
||||||
import type { Title } from "../../api/models/Title";
|
|
||||||
|
|
||||||
export function TitleCardSquare({ title }: { title: Title }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: 160,
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 8,
|
|
||||||
textAlign: "center"
|
|
||||||
}}>
|
|
||||||
{title.posterUrl && (
|
|
||||||
<img src={title.posterUrl} width={140} />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h4>{title.name}</h4>
|
|
||||||
<small>{title.year} • {title.rating}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// import React, { useEffect, useState } from "react";
|
|
||||||
// import { useParams } from "react-router-dom";
|
|
||||||
// import { DefaultService } from "../../api/services/DefaultService";
|
|
||||||
// import type { User } from "../../api/models/User";
|
|
||||||
// import styles from "./UserPage.module.css";
|
|
||||||
|
|
||||||
// const UserPage: React.FC = () => {
|
|
||||||
// const { id } = useParams<{ id: string }>();
|
|
||||||
// const [user, setUser] = useState<User | null>(null);
|
|
||||||
// const [loading, setLoading] = useState(true);
|
|
||||||
// const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (!id) return;
|
|
||||||
|
|
||||||
// const getTitleInfo = async () => {
|
|
||||||
// try {
|
|
||||||
// const userInfo = await DefaultService.getTitle(id, "all");
|
|
||||||
// setUser(userInfo);
|
|
||||||
// } catch (err) {
|
|
||||||
// console.error(err);
|
|
||||||
// setError("Failed to fetch user info.");
|
|
||||||
// } finally {
|
|
||||||
// setLoading(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
// getTitleInfo();
|
|
||||||
// }, [id]);
|
|
||||||
|
|
||||||
// if (loading) return <div className={styles.loader}>Loading...</div>;
|
|
||||||
// if (error) return <div className={styles.error}>{error}</div>;
|
|
||||||
// if (!user) return <div className={styles.error}>User not found.</div>;
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className={styles.container}>
|
|
||||||
// <div className={styles.card}>
|
|
||||||
// <div className={styles.avatar}>
|
|
||||||
// {user.avatar_id ? (
|
|
||||||
// <img
|
|
||||||
// src={`/images/${user.avatar_id}.png`}
|
|
||||||
// alt="User Avatar"
|
|
||||||
// className={styles.avatarImg}
|
|
||||||
// />
|
|
||||||
// ) : (
|
|
||||||
// <div className={styles.avatarPlaceholder}>
|
|
||||||
// {user.disp_name?.[0] || "U"}
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className={styles.info}>
|
|
||||||
// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
|
|
||||||
// <p className={styles.nickname}>@{user.nickname}</p>
|
|
||||||
// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
|
|
||||||
// <p className={styles.created}>
|
|
||||||
// Joined: {new Date(user.creation_date).toLocaleDateString()}
|
|
||||||
// </p>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// export default UserPage;
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { DefaultService } from "../../api/services/DefaultService";
|
|
||||||
import type { Title } from "../../api/models/Title";
|
|
||||||
import styles from "./TitlesPage.module.css";
|
|
||||||
|
|
||||||
const LIMIT = 20;
|
|
||||||
|
|
||||||
const TitlesPage: React.FC = () => {
|
|
||||||
const [titles, setTitles] = useState<Title[]>([]);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchTitles = async (reset: boolean) => {
|
|
||||||
try {
|
|
||||||
if (reset) {
|
|
||||||
setLoading(true);
|
|
||||||
setOffset(0);
|
|
||||||
} else {
|
|
||||||
setLoadingMore(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await DefaultService.getTitles(
|
|
||||||
search || undefined,
|
|
||||||
undefined, // status
|
|
||||||
undefined, // rating
|
|
||||||
undefined, // release_year
|
|
||||||
undefined, // release_season
|
|
||||||
LIMIT,
|
|
||||||
reset ? 0 : offset,
|
|
||||||
"all"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (reset) {
|
|
||||||
setTitles(result);
|
|
||||||
} else {
|
|
||||||
setTitles(prev => [...prev, ...result]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
setOffset(prev => prev + LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError("Failed to fetch titles.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTitles(true);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
if (loading) return <div className={styles.loader}>Loading...</div>;
|
|
||||||
if (error) return <div className={styles.error}>{error}</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<h1>Titles</h1>
|
|
||||||
|
|
||||||
<input
|
|
||||||
className={styles.searchInput}
|
|
||||||
placeholder="Search titles..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.list}>
|
|
||||||
{titles.map((t) => (
|
|
||||||
<div key={t.id} className={styles.card}>
|
|
||||||
{t.poster_id ? (
|
|
||||||
<img
|
|
||||||
src={`/images/${t.poster_id}.png`}
|
|
||||||
alt="Poster"
|
|
||||||
className={styles.poster}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.posterPlaceholder}>No Image</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.cardInfo}>
|
|
||||||
<h3 className={styles.titleName}>{t.name}</h3>
|
|
||||||
<p className={styles.meta}>
|
|
||||||
{t.release_year} • {t.release_season}
|
|
||||||
</p>
|
|
||||||
<p className={styles.rating}>Rating: {t.rating}</p>
|
|
||||||
<p className={styles.status}>{t.status}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{titles.length > 0 && (
|
|
||||||
<button
|
|
||||||
className={styles.loadMore}
|
|
||||||
onClick={() => fetchTitles(false)}
|
|
||||||
disabled={loadingMore}
|
|
||||||
>
|
|
||||||
{loadingMore ? "Loading..." : "Load More"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TitlesPage;
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
body,
|
|
||||||
html {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
background-color: #777;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
width: 100vw;
|
|
||||||
padding: 30px 40px;
|
|
||||||
background: #f7f7f7;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 25px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
||||||
border-bottom: 1px solid #e5e5e5;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarWrapper {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
min-width: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarImg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarPlaceholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ccc;
|
|
||||||
font-size: 42px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #555;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userInfo {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nickname {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 100vw;
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0%;
|
|
||||||
/* margin: 25px auto; */
|
|
||||||
/* padding: 0 20px; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.created {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader,
|
|
||||||
.error {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 40px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export interface PaginatedResult<TItem> {
|
|
||||||
items: TItem[];
|
|
||||||
nextCursor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FetchParams {
|
|
||||||
search: string;
|
|
||||||
cursor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FetchFunction<TItem> =
|
|
||||||
(params: FetchParams) => Promise<PaginatedResult<TItem>>;
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": false,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": false,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, er
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
items := [][]byte{}
|
var items [][]byte
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var tag_names []byte
|
var tag_names []byte
|
||||||
if err := rows.Scan(&tag_names); err != nil {
|
if err := rows.Scan(&tag_names); err != nil {
|
||||||
|
|
@ -289,131 +289,75 @@ const searchTitles = `-- name: SearchTitles :many
|
||||||
SELECT
|
SELECT
|
||||||
id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len
|
id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len
|
||||||
FROM titles
|
FROM titles
|
||||||
WHERE
|
WHERE
|
||||||
CASE
|
CASE
|
||||||
WHEN $1::boolean THEN
|
WHEN $1::text IS NOT NULL THEN
|
||||||
-- forward: greater than cursor (next page)
|
(
|
||||||
CASE $2::text
|
SELECT bool_and(
|
||||||
WHEN 'year' THEN
|
EXISTS (
|
||||||
($3::int IS NULL) OR
|
SELECT 1
|
||||||
(release_year > $3::int) OR
|
FROM jsonb_each_text(title_names) AS t(key, val)
|
||||||
(release_year = $3::int AND id > $4::bigint)
|
WHERE val ILIKE pattern
|
||||||
|
)
|
||||||
WHEN 'rating' THEN
|
)
|
||||||
($5::float IS NULL) OR
|
FROM unnest(
|
||||||
(rating > $5::float) OR
|
ARRAY(
|
||||||
(rating = $5::float AND id > $4::bigint)
|
SELECT '%' || trim(w) || '%'
|
||||||
|
FROM unnest(string_to_array($1::text, ' ')) AS w
|
||||||
WHEN 'id' THEN
|
WHERE trim(w) <> ''
|
||||||
($4::bigint IS NULL) OR
|
)
|
||||||
(id > $4::bigint)
|
) AS pattern
|
||||||
|
)
|
||||||
ELSE true -- fallback
|
ELSE true
|
||||||
END
|
|
||||||
|
|
||||||
ELSE
|
|
||||||
-- backward: less than cursor (prev page)
|
|
||||||
CASE $2::text
|
|
||||||
WHEN 'year' THEN
|
|
||||||
($3::int IS NULL) OR
|
|
||||||
(release_year < $3::int) OR
|
|
||||||
(release_year = $3::int AND id < $4::bigint)
|
|
||||||
|
|
||||||
WHEN 'rating' THEN
|
|
||||||
($5::float IS NULL) OR
|
|
||||||
(rating < $5::float) OR
|
|
||||||
(rating = $5::float AND id < $4::bigint)
|
|
||||||
|
|
||||||
WHEN 'id' THEN
|
|
||||||
($4::bigint IS NULL) OR
|
|
||||||
(id < $4::bigint)
|
|
||||||
|
|
||||||
ELSE true
|
|
||||||
END
|
|
||||||
END
|
END
|
||||||
|
|
||||||
AND (
|
AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t)
|
||||||
CASE
|
AND ($3::float IS NULL OR rating >= $3::float)
|
||||||
WHEN $6::text IS NOT NULL THEN
|
AND ($4::int IS NULL OR release_year = $4::int)
|
||||||
(
|
AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t)
|
||||||
SELECT bool_and(
|
ORDER BY CASE
|
||||||
EXISTS (
|
WHEN $6::boolean AND $7::text = 'name' THEN name
|
||||||
SELECT 1
|
WHEN $8::boolean AND $7::text = 'id' THEN id
|
||||||
FROM jsonb_each_text(title_names) AS t(key, val)
|
WHEN $8::boolean AND $7::text = 'name' THEN name
|
||||||
WHERE val ILIKE pattern
|
WHEN $8::boolean AND $7::text = 'id' THEN id
|
||||||
)
|
END ASC, CASE
|
||||||
)
|
WHEN NOT $8::boolean AND $7::text = 'name' THEN name
|
||||||
FROM unnest(
|
WHEN NOT $8::boolean AND $7::text = 'id' THEN id
|
||||||
ARRAY(
|
WHEN NOT $8::boolean AND $7::text = 'name' THEN name
|
||||||
SELECT '%' || trim(w) || '%'
|
WHEN NOT $8::boolean AND $7::text = 'id' THEN id
|
||||||
FROM unnest(string_to_array($6::text, ' ')) AS w
|
END DESC
|
||||||
WHERE trim(w) <> ''
|
LIMIT COALESCE($9::int, 100)
|
||||||
)
|
|
||||||
) AS pattern
|
|
||||||
)
|
|
||||||
ELSE true
|
|
||||||
END
|
|
||||||
)
|
|
||||||
|
|
||||||
AND ($7::title_status_t IS NULL OR title_status = $7::title_status_t)
|
|
||||||
AND ($8::float IS NULL OR rating >= $8::float)
|
|
||||||
AND ($9::int IS NULL OR release_year = $9::int)
|
|
||||||
AND ($10::release_season_t IS NULL OR release_season = $10::release_season_t)
|
|
||||||
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN $1::boolean THEN
|
|
||||||
CASE
|
|
||||||
WHEN $2::text = 'id' THEN id
|
|
||||||
WHEN $2::text = 'year' THEN release_year
|
|
||||||
WHEN $2::text = 'rating' THEN rating
|
|
||||||
END
|
|
||||||
END ASC,
|
|
||||||
CASE WHEN NOT $1::boolean THEN
|
|
||||||
CASE
|
|
||||||
WHEN $2::text = 'id' THEN id
|
|
||||||
WHEN $2::text = 'year' THEN release_year
|
|
||||||
WHEN $2::text = 'rating' THEN rating
|
|
||||||
END
|
|
||||||
END DESC,
|
|
||||||
|
|
||||||
CASE WHEN $2::text <> 'id' THEN id END ASC
|
|
||||||
|
|
||||||
LIMIT COALESCE($11::int, 100)
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type SearchTitlesParams struct {
|
type SearchTitlesParams struct {
|
||||||
Forward bool `json:"forward"`
|
|
||||||
SortBy string `json:"sort_by"`
|
|
||||||
CursorYear *int32 `json:"cursor_year"`
|
|
||||||
CursorID *int64 `json:"cursor_id"`
|
|
||||||
CursorRating *float64 `json:"cursor_rating"`
|
|
||||||
Word *string `json:"word"`
|
Word *string `json:"word"`
|
||||||
Status *TitleStatusT `json:"status"`
|
Status *TitleStatusT `json:"status"`
|
||||||
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"`
|
||||||
|
Forward bool `json:"forward"`
|
||||||
|
OrderBy string `json:"order_by"`
|
||||||
|
Reverse bool `json:"reverse"`
|
||||||
Limit *int32 `json:"limit"`
|
Limit *int32 `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) {
|
func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) {
|
||||||
rows, err := q.db.Query(ctx, searchTitles,
|
rows, err := q.db.Query(ctx, searchTitles,
|
||||||
arg.Forward,
|
|
||||||
arg.SortBy,
|
|
||||||
arg.CursorYear,
|
|
||||||
arg.CursorID,
|
|
||||||
arg.CursorRating,
|
|
||||||
arg.Word,
|
arg.Word,
|
||||||
arg.Status,
|
arg.Status,
|
||||||
arg.Rating,
|
arg.Rating,
|
||||||
arg.ReleaseYear,
|
arg.ReleaseYear,
|
||||||
arg.ReleaseSeason,
|
arg.ReleaseSeason,
|
||||||
|
arg.Forward,
|
||||||
|
arg.OrderBy,
|
||||||
|
arg.Reverse,
|
||||||
arg.Limit,
|
arg.Limit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
items := []Title{}
|
var items []Title
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Title
|
var i Title
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
|
|
@ -511,6 +455,7 @@ type SearchUserTitlesRow struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 100 is default limit
|
// 100 is default limit
|
||||||
|
// OFFSET sqlc.narg('offset')::int;
|
||||||
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.Word,
|
arg.Word,
|
||||||
|
|
@ -525,7 +470,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
items := []SearchUserTitlesRow{}
|
var items []SearchUserTitlesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i SearchUserTitlesRow
|
var i SearchUserTitlesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ sql:
|
||||||
sql_driver: "github.com/jackc/pgx/v5"
|
sql_driver: "github.com/jackc/pgx/v5"
|
||||||
emit_json_tags: true
|
emit_json_tags: true
|
||||||
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
|
|
||||||
overrides:
|
overrides:
|
||||||
- db_type: "uuid"
|
- db_type: "uuid"
|
||||||
nullable: false
|
nullable: false
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue