diff --git a/api/_build/oapi-codegen.yaml b/api/_build/oapi-codegen.yaml new file mode 100644 index 0000000..32e029a --- /dev/null +++ b/api/_build/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: oapi +generate: + strict-server: true + gin-server: true + models: true +output: api/api.gen.go \ No newline at end of file diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml new file mode 100644 index 0000000..720b686 --- /dev/null +++ b/api/_build/openapi.yaml @@ -0,0 +1,669 @@ +openapi: 3.0.4 +info: + title: 'Titles, Users, Reviews, Tags, and Media API' + version: 1.0.0 +servers: + - url: /api/v1 +paths: + /titles: + get: + summary: Get titles + parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - name: sort_forward + in: query + schema: + type: boolean + default: true + - name: word + in: query + schema: + type: string + - name: status + in: query + description: List of title statuses to filter + schema: + type: array + items: + $ref: '#/components/schemas/TitleStatus' + explode: false + style: form + - name: rating + in: query + schema: + type: number + format: double + - name: release_year + in: query + schema: + type: integer + format: int32 + - name: release_season + in: query + schema: + $ref: '#/components/schemas/ReleaseSeason' + - name: limit + in: query + schema: + type: integer + format: int32 + default: 10 + - name: offset + in: query + schema: + type: integer + format: int32 + default: 0 + - name: fields + in: query + schema: + type: string + default: all + responses: + '200': + description: List of titles with cursor + content: + application/json: + schema: + type: object + properties: + data: + description: List of titles + type: array + items: + $ref: '#/components/schemas/Title' + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '/titles/{title_id}': + get: + operationId: getTitle + summary: Get title description + parameters: + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 + - name: fields + in: query + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: '#/components/schemas/Title' + '204': + description: No title found + '400': + description: Request params are not correct + '404': + description: Title not found + '500': + description: Unknown server error + '/users/{user_id}': + get: + operationId: getUsersId + summary: Get user info + parameters: + - name: user_id + in: path + required: true + schema: + type: string + - name: fields + in: query + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Request params are not correct + '404': + description: User not found + '500': + description: Unknown server error + patch: + operationId: updateUser + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + parameters: + - name: user_id + in: path + description: User ID (primary key) + required: true + schema: + type: integer + format: int64 + example: 123 + requestBody: + required: true + content: + application/json: + schema: + description: Only provided fields are updated. Omitted fields remain unchanged. + type: object + properties: + avatar_id: + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + type: integer + format: int64 + example: 42 + nullable: true + mail: + description: User email (must be unique and valid) + type: string + format: email + example: john.doe.updated@example.com + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + nickname: + description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + type: string + example: john_doe_43 + maxLength: 16 + minLength: 3 + pattern: '^[a-zA-Z0-9_-]{3,16}$' + disp_name: + description: Display name + type: string + example: John Smith + maxLength: 32 + user_desc: + description: User description / bio + type: string + example: Just a curious developer. + maxLength: 512 + additionalProperties: false + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: 'Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)' + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: 'Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)' + '404': + description: User not found + '409': + description: 'Conflict — e.g., requested `nickname` or `mail` already taken by another user' + '422': + description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' + '500': + description: Unknown server error + '/users/{user_id}/titles': + get: + summary: Get user titles + parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - name: user_id + in: path + required: true + schema: + type: string + - name: sort_forward + in: query + schema: + type: boolean + default: true + - name: word + in: query + schema: + type: string + - name: status + in: query + description: List of title statuses to filter + schema: + type: array + items: + $ref: '#/components/schemas/TitleStatus' + explode: false + style: form + - name: watch_status + in: query + schema: + type: array + items: + $ref: '#/components/schemas/UserTitleStatus' + explode: false + style: form + - name: rating + in: query + schema: + type: number + format: double + - name: my_rate + in: query + schema: + type: integer + format: int32 + - name: release_year + in: query + schema: + type: integer + format: int32 + - name: release_season + in: query + schema: + $ref: '#/components/schemas/ReleaseSeason' + - name: limit + in: query + schema: + type: integer + format: int32 + default: 10 + - name: fields + in: query + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserTitle' + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '404': + description: User not found + '500': + description: Unknown server error + post: + operationId: addUserTitle + summary: Add a title to a user + description: 'User adding title to list af watched, status required' + parameters: + - name: user_id + in: path + description: ID of the user to assign the title to + required: true + schema: + type: integer + format: int64 + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + required: + - title_id + - status + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error + patch: + operationId: updateUserTitle + summary: Update a usertitle + description: User updating title list of watched + parameters: + - name: user_id + in: path + description: ID of the user to assign the title to + required: true + schema: + type: integer + format: int64 + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + required: + - title_id + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error +components: + parameters: + cursor: + in: query + name: cursor + required: false + schema: + type: string + title_sort: + in: query + name: sort + required: false + schema: + $ref: '#/components/schemas/TitleSort' + schemas: + TitleSort: + description: Title sort order + type: string + default: id + enum: + - id + - year + - rating + - views + TitleStatus: + description: Title status + type: string + enum: + - finished + - ongoing + - planned + ReleaseSeason: + description: Title release season + type: string + enum: + - winter + - spring + - summer + - fall + StorageType: + description: Image storage type + type: string + enum: + - s3 + - local + Image: + type: object + properties: + id: + type: integer + format: int64 + storage_type: + $ref: '#/components/schemas/StorageType' + image_path: + type: string + Studio: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: '#/components/schemas/Image' + description: + type: string + required: + - id + - name + Tag: + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + type: object + example: + en: Shojo + ru: Сёдзё + ja: 少女 + additionalProperties: + type: string + Tags: + description: Array of localized tags + type: array + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Title: + type: object + properties: + id: + description: Unique title ID (primary key) + type: integer + format: int64 + example: 1 + title_names: + description: 'Localized titles. Key = language (ISO 639-1), value = list of names' + type: object + example: + en: + - Attack on Titan + - AoT + ru: + - Атака титанов + - Титаны + ja: + - 進撃の巨人 + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT + studio: + $ref: '#/components/schemas/Studio' + tags: + $ref: '#/components/schemas/Tags' + poster: + $ref: '#/components/schemas/Image' + title_status: + $ref: '#/components/schemas/TitleStatus' + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: '#/components/schemas/ReleaseSeason' + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double + required: + - id + - title_names + - tags + CursorObj: + type: object + properties: + id: + type: integer + format: int64 + param: + type: string + required: + - id + User: + type: object + properties: + id: + description: Unique user ID (primary key) + type: integer + format: int64 + example: 1 + image: + $ref: '#/components/schemas/Image' + mail: + description: User email + type: string + format: email + example: john.doe@example.com + nickname: + description: Username (alphanumeric + _ or -) + type: string + example: john_doe_42 + maxLength: 16 + disp_name: + description: Display name + type: string + example: John Doe + maxLength: 32 + user_desc: + description: User description + type: string + example: Just a regular user. + maxLength: 512 + creation_date: + description: Timestamp when the user was created + type: string + format: date-time + example: '2025-10-10T23:45:47.908073Z' + required: + - user_id + - nickname + UserTitleStatus: + description: User's title status + type: string + enum: + - finished + - planned + - dropped + - in-progress + UserTitle: + type: object + properties: + user_id: + type: integer + format: int64 + title: + $ref: '#/components/schemas/Title' + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + required: + - user_id + - title_id + - status + UserTitleMini: + type: object + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + required: + - user_id + - title_id + - status + Review: + type: object + additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index 24aebd3..cb5c1ae 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,19 +16,123 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for ImageStorageType. +const ( + Local ImageStorageType = "local" + S3 ImageStorageType = "s3" +) + +// Defines values for ReleaseSeason. +const ( + Fall ReleaseSeason = "fall" + Spring ReleaseSeason = "spring" + Summer ReleaseSeason = "summer" + Winter ReleaseSeason = "winter" +) + +// Defines values for TitleSort. +const ( + Id TitleSort = "id" + Rating TitleSort = "rating" + Views TitleSort = "views" + Year TitleSort = "year" +) + +// Defines values for TitleStatus. +const ( + TitleStatusFinished TitleStatus = "finished" + TitleStatusOngoing TitleStatus = "ongoing" + TitleStatusPlanned TitleStatus = "planned" +) + +// Defines values for UserTitleStatus. +const ( + UserTitleStatusDropped UserTitleStatus = "dropped" + UserTitleStatusFinished UserTitleStatus = "finished" + UserTitleStatusInProgress UserTitleStatus = "in-progress" + UserTitleStatusPlanned UserTitleStatus = "planned" +) + +// CursorObj defines model for CursorObj. +type CursorObj struct { + Id int64 `json:"id"` + Param *string `json:"param,omitempty"` +} + +// Image defines model for Image. +type Image struct { + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + + // StorageType Image storage type + StorageType *ImageStorageType `json:"storage_type,omitempty"` +} + +// ImageStorageType Image storage type +type ImageStorageType string + +// ReleaseSeason Title release season +type ReleaseSeason string + +// Studio defines model for Studio. +type Studio struct { + Description *string `json:"description,omitempty"` + Id int64 `json:"id"` + Name string `json:"name"` + Poster *Image `json:"poster,omitempty"` +} + +// Tag A localized tag: keys are language codes (ISO 639-1), values are tag names +type Tag map[string]string + +// Tags Array of localized tags +type Tags = []Tag + +// Title defines model for Title. +type Title struct { + EpisodesAired *int32 `json:"episodes_aired,omitempty"` + EpisodesAll *int32 `json:"episodes_all,omitempty"` + EpisodesLen *map[string]float64 `json:"episodes_len,omitempty"` + + // Id Unique title ID (primary key) + Id int64 `json:"id"` + Poster *Image `json:"poster,omitempty"` + Rating *float64 `json:"rating,omitempty"` + RatingCount *int32 `json:"rating_count,omitempty"` + + // ReleaseSeason Title release season + ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` + ReleaseYear *int32 `json:"release_year,omitempty"` + Studio *Studio `json:"studio,omitempty"` + + // Tags Array of localized tags + Tags Tags `json:"tags"` + + // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names + TitleNames map[string][]string `json:"title_names"` + + // TitleStatus Title status + TitleStatus *TitleStatus `json:"title_status,omitempty"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// TitleSort Title sort order +type TitleSort string + +// TitleStatus Title status +type TitleStatus string + // User defines model for User. type User struct { - // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id"` - // CreationDate Timestamp when the user was created - CreationDate time.Time `json:"creation_date"` + CreationDate *time.Time `json:"creation_date,omitempty"` // DispName Display name DispName *string `json:"disp_name,omitempty"` // Id Unique user ID (primary key) - Id *int64 `json:"id,omitempty"` + Id *int64 `json:"id,omitempty"` + Image *Image `json:"image,omitempty"` // Mail User email Mail *openapi_types.Email `json:"mail,omitempty"` @@ -40,16 +144,367 @@ type User struct { UserDesc *string `json:"user_desc,omitempty"` } +// UserTitle defines model for UserTitle. +type UserTitle struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + Title *Title `json:"title,omitempty"` + UserId int64 `json:"user_id"` +} + +// UserTitleStatus User's title status +type UserTitleStatus string + +// Cursor defines model for cursor. +type Cursor = string + +// GetTitlesParams defines parameters for GetTitles. +type GetTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitlesTitleIdParams defines parameters for GetTitlesTitleId. +type GetTitlesTitleIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + // GetUsersUserIdParams defines parameters for GetUsersUserId. type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserJSONBody defines parameters for UpdateUser. +type UpdateUserJSONBody struct { + // AvatarId ID of the user avatar (references `images.id`); set to `null` to remove avatar + AvatarId *int64 `json:"avatar_id"` + + // DispName Display name + DispName *string `json:"disp_name,omitempty"` + + // Mail User email (must be unique and valid) + Mail *openapi_types.Email `json:"mail,omitempty"` + + // Nickname Username (alphanumeric + `_` or `-`, 3–16 chars) + Nickname *string `json:"nickname,omitempty"` + + // UserDesc User description / bio + UserDesc *string `json:"user_desc,omitempty"` +} + +// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. +type GetUsersUserIdTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` + WatchStatus *[]UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// AddUserTitleJSONBody defines parameters for AddUserTitle. +type AddUserTitleJSONBody struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` +} + +// UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. +type UpdateUserJSONRequestBody UpdateUserJSONBody + +// AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. +type AddUserTitleJSONRequestBody AddUserTitleJSONBody + +// Getter for additional properties for Title. Returns the specified +// element and whether it was found +func (a Title) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for Title +func (a *Title) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a *Title) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["episodes_aired"]; found { + err = json.Unmarshal(raw, &a.EpisodesAired) + if err != nil { + return fmt.Errorf("error reading 'episodes_aired': %w", err) + } + delete(object, "episodes_aired") + } + + if raw, found := object["episodes_all"]; found { + err = json.Unmarshal(raw, &a.EpisodesAll) + if err != nil { + return fmt.Errorf("error reading 'episodes_all': %w", err) + } + delete(object, "episodes_all") + } + + if raw, found := object["episodes_len"]; found { + err = json.Unmarshal(raw, &a.EpisodesLen) + if err != nil { + return fmt.Errorf("error reading 'episodes_len': %w", err) + } + delete(object, "episodes_len") + } + + if raw, found := object["id"]; found { + err = json.Unmarshal(raw, &a.Id) + if err != nil { + return fmt.Errorf("error reading 'id': %w", err) + } + delete(object, "id") + } + + if raw, found := object["poster"]; found { + err = json.Unmarshal(raw, &a.Poster) + if err != nil { + return fmt.Errorf("error reading 'poster': %w", err) + } + delete(object, "poster") + } + + if raw, found := object["rating"]; found { + err = json.Unmarshal(raw, &a.Rating) + if err != nil { + return fmt.Errorf("error reading 'rating': %w", err) + } + delete(object, "rating") + } + + if raw, found := object["rating_count"]; found { + err = json.Unmarshal(raw, &a.RatingCount) + if err != nil { + return fmt.Errorf("error reading 'rating_count': %w", err) + } + delete(object, "rating_count") + } + + if raw, found := object["release_season"]; found { + err = json.Unmarshal(raw, &a.ReleaseSeason) + if err != nil { + return fmt.Errorf("error reading 'release_season': %w", err) + } + delete(object, "release_season") + } + + if raw, found := object["release_year"]; found { + err = json.Unmarshal(raw, &a.ReleaseYear) + if err != nil { + return fmt.Errorf("error reading 'release_year': %w", err) + } + delete(object, "release_year") + } + + if raw, found := object["studio"]; found { + err = json.Unmarshal(raw, &a.Studio) + if err != nil { + return fmt.Errorf("error reading 'studio': %w", err) + } + delete(object, "studio") + } + + if raw, found := object["tags"]; found { + err = json.Unmarshal(raw, &a.Tags) + if err != nil { + return fmt.Errorf("error reading 'tags': %w", err) + } + delete(object, "tags") + } + + if raw, found := object["title_names"]; found { + err = json.Unmarshal(raw, &a.TitleNames) + if err != nil { + return fmt.Errorf("error reading 'title_names': %w", err) + } + delete(object, "title_names") + } + + if raw, found := object["title_status"]; found { + err = json.Unmarshal(raw, &a.TitleStatus) + if err != nil { + return fmt.Errorf("error reading 'title_status': %w", err) + } + delete(object, "title_status") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a Title) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.EpisodesAired != nil { + object["episodes_aired"], err = json.Marshal(a.EpisodesAired) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_aired': %w", err) + } + } + + if a.EpisodesAll != nil { + object["episodes_all"], err = json.Marshal(a.EpisodesAll) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_all': %w", err) + } + } + + if a.EpisodesLen != nil { + object["episodes_len"], err = json.Marshal(a.EpisodesLen) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_len': %w", err) + } + } + + object["id"], err = json.Marshal(a.Id) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) + } + + if a.Poster != nil { + object["poster"], err = json.Marshal(a.Poster) + if err != nil { + return nil, fmt.Errorf("error marshaling 'poster': %w", err) + } + } + + if a.Rating != nil { + object["rating"], err = json.Marshal(a.Rating) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating': %w", err) + } + } + + if a.RatingCount != nil { + object["rating_count"], err = json.Marshal(a.RatingCount) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating_count': %w", err) + } + } + + if a.ReleaseSeason != nil { + object["release_season"], err = json.Marshal(a.ReleaseSeason) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_season': %w", err) + } + } + + if a.ReleaseYear != nil { + object["release_year"], err = json.Marshal(a.ReleaseYear) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_year': %w", err) + } + } + + if a.Studio != nil { + object["studio"], err = json.Marshal(a.Studio) + if err != nil { + return nil, fmt.Errorf("error marshaling 'studio': %w", err) + } + } + + object["tags"], err = json.Marshal(a.Tags) + if err != nil { + return nil, fmt.Errorf("error marshaling 'tags': %w", err) + } + + object["title_names"], err = json.Marshal(a.TitleNames) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_names': %w", err) + } + + if a.TitleStatus != nil { + object["title_status"], err = json.Marshal(a.TitleStatus) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_status': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // ServerInterface represents all server handlers. type ServerInterface interface { + // Get titles + // (GET /titles) + GetTitles(c *gin.Context, params GetTitlesParams) + // Get title description + // (GET /titles/{title_id}) + GetTitlesTitleId(c *gin.Context, titleId int64, params GetTitlesTitleIdParams) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(c *gin.Context, userId int64) + // Get user titles + // (GET /users/{user_id}/titles) + GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(c *gin.Context, userId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -61,6 +516,147 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// GetTitles operation middleware +func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitlesParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitles(c, params) +} + +// GetTitlesTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitlesTitleIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitlesTitleId(c, titleId, params) +} + // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -96,6 +692,177 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { siw.Handler.GetUsersUserId(c, userId, params) } +// UpdateUser operation middleware +func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUser(c, userId) +} + +// GetUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdTitlesParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "watch_status" ------------- + + err = runtime.BindQueryParameter("form", false, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter watch_status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "my_rate" ------------- + + err = runtime.BindQueryParameter("form", true, false, "my_rate", c.Request.URL.Query(), ¶ms.MyRate) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter my_rate: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdTitles(c, userId, params) +} + +// AddUserTitle operation middleware +func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.AddUserTitle(c, userId) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -123,7 +890,108 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.GET(options.BaseURL+"/titles", wrapper.GetTitles) + router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) +} + +type GetTitlesRequestObject struct { + Params GetTitlesParams +} + +type GetTitlesResponseObject interface { + VisitGetTitlesResponse(w http.ResponseWriter) error +} + +type GetTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + + // Data List of titles + Data []Title `json:"data"` +} + +func (response GetTitles200JSONResponse) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitles204Response struct { +} + +func (response GetTitles204Response) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitles400Response struct { +} + +func (response GetTitles400Response) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetTitles500Response struct { +} + +func (response GetTitles500Response) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetTitlesTitleIdRequestObject struct { + TitleId int64 `json:"title_id"` + Params GetTitlesTitleIdParams +} + +type GetTitlesTitleIdResponseObject interface { + VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error +} + +type GetTitlesTitleId200JSONResponse Title + +func (response GetTitlesTitleId200JSONResponse) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitlesTitleId204Response struct { +} + +func (response GetTitlesTitleId204Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitlesTitleId400Response struct { +} + +func (response GetTitlesTitleId400Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetTitlesTitleId404Response struct { +} + +func (response GetTitlesTitleId404Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetTitlesTitleId500Response struct { +} + +func (response GetTitlesTitleId500Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil } type GetUsersUserIdRequestObject struct { @@ -144,6 +1012,14 @@ func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http return json.NewEncoder(w).Encode(response) } +type GetUsersUserId400Response struct { +} + +func (response GetUsersUserId400Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + type GetUsersUserId404Response struct { } @@ -152,11 +1028,236 @@ func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type GetUsersUserId500Response struct { +} + +func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type UpdateUserRequestObject struct { + UserId int64 `json:"user_id"` + Body *UpdateUserJSONRequestBody +} + +type UpdateUserResponseObject interface { + VisitUpdateUserResponse(w http.ResponseWriter) error +} + +type UpdateUser200JSONResponse User + +func (response UpdateUser200JSONResponse) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUser400Response struct { +} + +func (response UpdateUser400Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUser401Response struct { +} + +func (response UpdateUser401Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUser403Response struct { +} + +func (response UpdateUser403Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUser404Response struct { +} + +func (response UpdateUser404Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUser409Response struct { +} + +func (response UpdateUser409Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type UpdateUser422Response struct { +} + +func (response UpdateUser422Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(422) + return nil +} + +type UpdateUser500Response struct { +} + +func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdTitlesParams +} + +type GetUsersUserIdTitlesResponseObject interface { + VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + Data []UserTitle `json:"data"` +} + +func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitles204Response struct { +} + +func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsersUserIdTitles400Response struct { +} + +func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsersUserIdTitles404Response struct { +} + +func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetUsersUserIdTitles500Response struct { +} + +func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type AddUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + Body *AddUserTitleJSONRequestBody +} + +type AddUserTitleResponseObject interface { + VisitAddUserTitleResponse(w http.ResponseWriter) error +} + +type AddUserTitle200JSONResponse struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` +} + +func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AddUserTitle400Response struct { +} + +func (response AddUserTitle400Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type AddUserTitle401Response struct { +} + +func (response AddUserTitle401Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type AddUserTitle403Response struct { +} + +func (response AddUserTitle403Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type AddUserTitle404Response struct { +} + +func (response AddUserTitle404Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type AddUserTitle409Response struct { +} + +func (response AddUserTitle409Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type AddUserTitle500Response struct { +} + +func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get titles + // (GET /titles) + GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) + // Get title description + // (GET /titles/{title_id}) + GetTitlesTitleId(ctx context.Context, request GetTitlesTitleIdRequestObject) (GetTitlesTitleIdResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) + // Get user titles + // (GET /users/{user_id}/titles) + GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -171,6 +1272,61 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetTitles operation middleware +func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { + var request GetTitlesRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitles(ctx, request.(GetTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitlesResponseObject); ok { + if err := validResponse.VisitGetTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitlesTitleId operation middleware +func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, params GetTitlesTitleIdParams) { + var request GetTitlesTitleIdRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitlesTitleId(ctx, request.(GetTitlesTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitlesTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitlesTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitlesTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject @@ -198,3 +1354,101 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// UpdateUser operation middleware +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { + var request UpdateUserRequestObject + + request.UserId = userId + + var body UpdateUserJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUser(ctx, request.(UpdateUserRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUser") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserResponseObject); ok { + if err := validResponse.VisitUpdateUserResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserIdTitles operation middleware +func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { + var request GetUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// AddUserTitle operation middleware +func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { + var request AddUserTitleRequestObject + + request.UserId = userId + + var body AddUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.AddUserTitle(ctx, request.(AddUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AddUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(AddUserTitleResponseObject); ok { + if err := validResponse.VisitAddUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index b20f677..7da26f8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,592 +1,24 @@ -openapi: 3.1.1 +openapi: 3.0.4 info: title: Titles, Users, Reviews, Tags, and Media API version: 1.0.0 + servers: - url: /api/v1 + paths: - # /title: - # get: - # summary: Get titles - # parameters: - # - in: query - # name: query - # schema: - # type: string - # - in: query - # name: limit - # schema: - # type: integer - # default: 10 - # - in: query - # name: offset - # schema: - # type: integer - # default: 0 - # - in: query - # name: fields - # schema: - # type: string - # default: all - # responses: - # '200': - # description: List of titles - # content: - # application/json: - # schema: - # type: array - # items: - # $ref: '#/components/schemas/Title' - # '204': - # description: No titles found - -# /title/{title_id}: -# get: -# summary: Get title description -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# - in: query -# name: fields -# schema: -# type: string -# default: all -# responses: -# '200': -# description: Title description -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Title' -# '404': -# description: Title not found - -# patch: -# summary: Update title info -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Title' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# user_json: -# $ref: '#/components/schemas/User' - -# /title/{title_id}/reviews: -# get: -# summary: Get title reviews -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# responses: -# '200': -# description: List of reviews -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Review' -# '204': -# description: No reviews found - + /titles: + $ref: "./paths/titles.yaml" + /titles/{title_id}: + $ref: "./paths/titles-id.yaml" /users/{user_id}: - get: - summary: Get user info - parameters: - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: User info - content: - application/json: - schema: - $ref: '#/components/schemas/User' - '404': - description: User not found - - # patch: - # summary: Update user - # parameters: - # - in: path - # name: user_id - # required: true - # schema: - # type: string - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Update result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - - # delete: - # summary: Delete user - # parameters: - # - in: path - # name: user_id - # required: true - # schema: - # type: string - # responses: - # '200': - # description: Delete result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - - # /users: - # get: - # summary: Search user - # parameters: - # - in: query - # name: query - # schema: - # type: string - # - in: query - # name: fields - # schema: - # type: string - # responses: - # '200': - # description: List of users - # content: - # application/json: - # schema: - # type: array - # items: - # $ref: '#/components/schemas/User' - - # post: - # summary: Add new user - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Add result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - # user_json: - # $ref: '#/components/schemas/User' - -# /users/{user_id}/titles: -# get: -# summary: Get user titles -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: query -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# - in: query -# name: fields -# schema: -# type: string -# default: all -# responses: -# '200': -# description: List of user titles -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/UserTitle' -# '204': -# description: No titles found - -# post: -# summary: Add user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# type: object -# properties: -# title_id: -# type: string -# status: -# type: string -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# patch: -# summary: Update user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/UserTitle' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# delete: -# summary: Delete user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: title_id -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /users/{user_id}/reviews: -# get: -# summary: Get user reviews -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# responses: -# '200': -# description: List of reviews -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Review' - -# /reviews: -# post: -# summary: Add review -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Review' -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /reviews/{review_id}: -# patch: -# summary: Update review -# parameters: -# - in: path -# name: review_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Review' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# delete: -# summary: Delete review -# parameters: -# - in: path -# name: review_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /tags: -# get: -# summary: Get tags -# parameters: -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# - in: query -# name: fields -# schema: -# type: string -# responses: -# '200': -# description: List of tags -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Tag' - -# /media: -# post: -# summary: Upload image -# responses: -# '200': -# description: Upload result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# image_id: -# type: string - -# get: -# summary: Get image path -# parameters: -# - in: query -# name: image_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Image path -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# image_path: -# type: string + $ref: "./paths/users-id.yaml" + /users/{user_id}/titles: + $ref: "./paths/users-id-titles.yaml" components: + parameters: + $ref: "./parameters/_index.yaml" schemas: - Title: - type: object - additionalProperties: true - User: - type: object - properties: - id: - type: integer - format: int64 - description: Unique user ID (primary key) - example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - nullable: true - example: null - mail: - type: string - format: email - description: User email - example: "john.doe@example.com" - nickname: - type: string - description: Username (alphanumeric + _ or -) - maxLength: 16 - example: "john_doe_42" - disp_name: - type: string - description: Display name - maxLength: 32 - example: "John Doe" - user_desc: - type: string - description: User description - maxLength: 512 - example: "Just a regular user." - creation_date: - type: string - format: date-time - description: Timestamp when the user was created - example: "2025-10-10T23:45:47.908073Z" - required: - - user_id - - nickname - - creation_date - UserTitle: - type: object - additionalProperties: true - Review: - type: object - additionalProperties: true - Tag: - type: object - additionalProperties: true + $ref: "./schemas/_index.yaml" + \ No newline at end of file diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml new file mode 100644 index 0000000..6249e7d --- /dev/null +++ b/api/parameters/_index.yaml @@ -0,0 +1,4 @@ +cursor: + $ref: "./cursor.yaml" +title_sort: + $ref: "./title_sort.yaml" \ No newline at end of file diff --git a/api/parameters/cursor.yaml b/api/parameters/cursor.yaml new file mode 100644 index 0000000..1730f18 --- /dev/null +++ b/api/parameters/cursor.yaml @@ -0,0 +1,5 @@ +in: query +name: cursor +required: false +schema: + type: string diff --git a/api/parameters/title_sort.yaml b/api/parameters/title_sort.yaml new file mode 100644 index 0000000..615294b --- /dev/null +++ b/api/parameters/title_sort.yaml @@ -0,0 +1,5 @@ +in: query +name: sort +required: false +schema: + $ref: '../schemas/TitleSort.yaml' diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml new file mode 100644 index 0000000..235743f --- /dev/null +++ b/api/paths/titles-id.yaml @@ -0,0 +1,30 @@ +get: + summary: Get title description + operationId: getTitle + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: "../schemas/Title.yaml" + '404': + description: Title not found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '204': + description: No title found diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml new file mode 100644 index 0000000..af2d17b --- /dev/null +++ b/api/paths/titles.yaml @@ -0,0 +1,79 @@ +get: + summary: Get titles + parameters: + - $ref: "../parameters/cursor.yaml" + - $ref: "../parameters/title_sort.yaml" + - in: query + name: sort_forward + schema: + type: boolean + default: true + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: false + + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '../schemas/enums/ReleaseSeason.yaml' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: offset + schema: + type: integer + format: int32 + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../schemas/Title.yaml' + description: List of titles + cursor: + $ref: '../schemas/CursorObj.yaml' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml new file mode 100644 index 0000000..4f11ab6 --- /dev/null +++ b/api/paths/users-id-titles.yaml @@ -0,0 +1,191 @@ +get: + summary: Get user titles + parameters: + - $ref: '../parameters/cursor.yaml' + - $ref: "../parameters/title_sort.yaml" + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: sort_forward + schema: + type: boolean + default: true + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: false + - in: query + name: watch_status + schema: + type: array + items: + $ref: '../schemas/enums/UserTitleStatus.yaml' + style: form + explode: false + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: my_rate + schema: + type: integer + format: int32 + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '../schemas/enums/ReleaseSeason.yaml' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../schemas/UserTitle.yaml' + cursor: + $ref: '../schemas/CursorObj.yaml' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '404': + description: User not found + '500': + description: Unknown server error + +post: + summary: Add a title to a user + description: User adding title to list af watched, status required + operationId: addUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + - status + properties: + title_id: + type: integer + format: int64 + status: + $ref: '../schemas/enums/UserTitleStatus.yaml' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + properties: + title_id: + type: integer + format: int64 + status: + $ref: '../schemas/enums/UserTitleStatus.yaml' + rate: + type: integer + format: int32 + + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error \ No newline at end of file diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml new file mode 100644 index 0000000..fe62e46 --- /dev/null +++ b/api/paths/users-id.yaml @@ -0,0 +1,103 @@ +get: + summary: Get user info + operationId: getUsersId + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '404': + description: User not found + '400': + description: Request params are not correct + '500': + description: Unknown server error + +patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: Username (alphanumeric + `_` or `-`, 3–16 chars) + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '400': + description: Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON) + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights) + '404': + description: User not found + '409': + description: Conflict — e.g., requested `nickname` or `mail` already taken by another user + '422': + description: Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`) + '500': + description: Unknown server error diff --git a/api/schemas/CursorObj.yaml b/api/schemas/CursorObj.yaml new file mode 100644 index 0000000..5a3c96c --- /dev/null +++ b/api/schemas/CursorObj.yaml @@ -0,0 +1,9 @@ +type: object +required: + - id +properties: + id: + type: integer + format: int64 + param: + type: string diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml new file mode 100644 index 0000000..3fb520b --- /dev/null +++ b/api/schemas/Image.yaml @@ -0,0 +1,10 @@ +type: object +properties: +# id выпиливаем + id: + type: integer + format: int64 + storage_type: + $ref: './enums/StorageType.yaml' + image_path: + type: string diff --git a/api/schemas/Review.yaml b/api/schemas/Review.yaml new file mode 100644 index 0000000..a116dde --- /dev/null +++ b/api/schemas/Review.yaml @@ -0,0 +1,2 @@ +type: object +additionalProperties: true diff --git a/api/schemas/Studio.yaml b/api/schemas/Studio.yaml new file mode 100644 index 0000000..26a2adf --- /dev/null +++ b/api/schemas/Studio.yaml @@ -0,0 +1,15 @@ +type: object +required: + - id + - name +properties: +# id не нужен + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: ./Image.yaml + description: + type: string diff --git a/api/schemas/Tag.yaml b/api/schemas/Tag.yaml new file mode 100644 index 0000000..7239b10 --- /dev/null +++ b/api/schemas/Tag.yaml @@ -0,0 +1,8 @@ +type: object +description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' +additionalProperties: + type: string +example: + en: Shojo + ru: Сёдзё + ja: 少女 diff --git a/api/schemas/Tags.yaml b/api/schemas/Tags.yaml new file mode 100644 index 0000000..ca8c4fd --- /dev/null +++ b/api/schemas/Tags.yaml @@ -0,0 +1,11 @@ +type: array +description: Array of localized tags +items: + $ref: ./Tag.yaml +example: +- en: Shojo + ru: Сёдзё + ja: 少女 +- en: Shounen + ru: Сёнен + ja: 少年 diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml new file mode 100644 index 0000000..877ee24 --- /dev/null +++ b/api/schemas/Title.yaml @@ -0,0 +1,62 @@ +type: object +required: + - id + - title_names + - tags +properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: object + description: Localized titles. Key = language (ISO 639-1), value = list of names + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT + example: + en: + - Attack on Titan + - AoT + ru: + - Атака титанов + - Титаны + ja: + - 進撃の巨人 + studio: + $ref: ./Studio.yaml + tags: + $ref: ./Tags.yaml + poster: + $ref: ./Image.yaml + title_status: + $ref: ./enums/TitleStatus.yaml + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: ./enums/ReleaseSeason.yaml + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double diff --git a/api/schemas/TitleSort.yaml b/api/schemas/TitleSort.yaml new file mode 100644 index 0000000..d8ce8f7 --- /dev/null +++ b/api/schemas/TitleSort.yaml @@ -0,0 +1,8 @@ +type: string +description: Title sort order +default: id +enum: + - id + - year + - rating + - views diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml new file mode 100644 index 0000000..4e53534 --- /dev/null +++ b/api/schemas/User.yaml @@ -0,0 +1,37 @@ +type: object +properties: + id: + type: integer + format: int64 + description: Unique user ID (primary key) + example: 1 + image: + $ref: '../schemas/Image.yaml' + mail: + type: string + format: email + description: User email + example: john.doe@example.com + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: john_doe_42 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Doe + user_desc: + type: string + description: User description + maxLength: 512 + example: Just a regular user. + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: '2025-10-10T23:45:47.908073Z' +required: + - user_id + - nickname diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml new file mode 100644 index 0000000..ef619cb --- /dev/null +++ b/api/schemas/UserTitle.yaml @@ -0,0 +1,22 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title: + $ref: ./Title.yaml + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml new file mode 100644 index 0000000..e1a5a74 --- /dev/null +++ b/api/schemas/UserTitleMini.yaml @@ -0,0 +1,23 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml new file mode 100644 index 0000000..d893ced --- /dev/null +++ b/api/schemas/_index.yaml @@ -0,0 +1,26 @@ +CursorObj: + $ref: "./CursorObj.yaml" +TitleSort: + $ref: "./TitleSort.yaml" +Image: + $ref: "./Image.yaml" +TitleStatus: + $ref: "./enums/TitleStatus.yaml" +ReleaseSeason: + $ref: "./enums/ReleaseSeason.yaml" +UserTitleStatus: + $ref: "./enums/UserTitleStatus.yaml" +Review: + $ref: "./Review.yaml" +Tag: + $ref: "./Tag.yaml" +Tags: + $ref: "./Tags.yaml" +Studio: + $ref: "./Studio.yaml" +Title: + $ref: "./Title.yaml" +User: + $ref: "./User.yaml" +UserTitle: + $ref: "./UserTitle.yaml" diff --git a/api/schemas/enums/ReleaseSeason.yaml b/api/schemas/enums/ReleaseSeason.yaml new file mode 100644 index 0000000..5cf988d --- /dev/null +++ b/api/schemas/enums/ReleaseSeason.yaml @@ -0,0 +1,7 @@ +type: string +description: Title release season +enum: + - winter + - spring + - summer + - fall diff --git a/api/schemas/enums/StorageType.yaml b/api/schemas/enums/StorageType.yaml new file mode 100644 index 0000000..1984a87 --- /dev/null +++ b/api/schemas/enums/StorageType.yaml @@ -0,0 +1,5 @@ +type: string +description: Image storage type +enum: + - s3 + - local \ No newline at end of file diff --git a/api/schemas/enums/TitleStatus.yaml b/api/schemas/enums/TitleStatus.yaml new file mode 100644 index 0000000..0bfce7d --- /dev/null +++ b/api/schemas/enums/TitleStatus.yaml @@ -0,0 +1,6 @@ +type: string +description: Title status +enum: + - finished + - ongoing + - planned diff --git a/api/schemas/enums/UserTitleStatus.yaml b/api/schemas/enums/UserTitleStatus.yaml new file mode 100644 index 0000000..0b1a90d --- /dev/null +++ b/api/schemas/enums/UserTitleStatus.yaml @@ -0,0 +1,7 @@ +type: string +description: User's title status +enum: + - finished + - planned + - dropped + - in-progress diff --git a/api/schemas/updateUser.yaml b/api/schemas/updateUser.yaml new file mode 100644 index 0000000..e1d77af --- /dev/null +++ b/api/schemas/updateUser.yaml @@ -0,0 +1,26 @@ +type: object +properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. +additionalProperties: false +description: Only provided fields are updated. Omitted fields remain unchanged. \ No newline at end of file diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index b1b10ca..0fe308c 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -3,6 +3,9 @@ info: title: Auth Service version: 1.0.0 +servers: + - url: /auth + paths: /auth/sign-up: post: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1a96253..7f53da5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -37,6 +37,18 @@ services: - "8080:8080" depends_on: - postgres + + nyanimedb-auth: + image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + container_name: nyanimedb-auth + restart: always + environment: + LOG_LEVEL: ${LOG_LEVEL} + DATABASE_URL: ${DATABASE_URL} + ports: + - "8082:8082" + depends_on: + - postgres nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest diff --git a/deploy/generate.sh b/deploy/generate.sh index d7d94a2..29587cf 100644 --- a/deploy/generate.sh +++ b/deploy/generate.sh @@ -1,3 +1,3 @@ -npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios +npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios --useUnionTypes oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml sqlc generate -f .\sql\sqlc.yaml \ No newline at end of file diff --git a/go.mod b/go.mod index 4089c02..72df275 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,10 @@ require ( github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 +<<<<<<< HEAD +======= + github.com/sirupsen/logrus v1.9.3 +>>>>>>> front ) require ( @@ -26,6 +30,7 @@ require ( github.com/google/uuid v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/go.sum b/go.sum index d8c4265..8f46514 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -97,6 +99,7 @@ golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go new file mode 100644 index 0000000..2cf2283 --- /dev/null +++ b/modules/backend/handlers/common.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + sqlc "nyanimedb/sql" + "strconv" +) + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { + if s == nil { + return nil, nil + } + var t oapi.ImageStorageType + switch *s { + case sqlc.StorageTypeTLocal: + t = oapi.Local + case sqlc.StorageTypeTS3: + t = oapi.S3 + default: + return nil, fmt.Errorf("unexpected storage type: %s", *s) + } + return &t, nil +} + +func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { + + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + // EpisodesLen: &episodes_lens, + Id: title.ID, + // Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + // ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + // Studio: &oapi_studio, + // Tags: oapi_tag_names, + // TitleNames: title_names, + // TitleStatus: oapi_status, + // AdditionalProperties: + } + + title_names := make(map[string][]string, 0) + err := json.Unmarshal(title.TitleNames, &title_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) + } + oapi_title.TitleNames = title_names + + if len(title.EpisodesLen) > 0 { + episodes_lens := make(map[string]float64, 0) + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } + oapi_title.EpisodesLen = &episodes_lens + } + + oapi_tag_names := make(oapi.Tags, 0) + err = json.Unmarshal(title.TagNames, &oapi_tag_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) + } + oapi_title.Tags = oapi_tag_names + + var oapi_studio oapi.Studio + if title.StudioName != nil { + oapi_studio.Name = *title.StudioName + } + if title.StudioID != 0 { + oapi_studio.Id = title.StudioID + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster = &oapi.Image{} + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + + s, err := sql2StorageType(title.StudioStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, studio storage type: %v", err) + } + oapi_studio.Poster.StorageType = s + + } + } + oapi_title.Studio = &oapi_studio + + var oapi_image oapi.Image + + if title.PosterID != nil { + oapi_image.Id = title.PosterID + oapi_image.ImagePath = title.TitleImagePath + s, err := sql2StorageType(title.TitleStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, title starage type: %v", err) + } + oapi_image.StorageType = s + } + oapi_title.Poster = &oapi_image + + var release_season oapi.ReleaseSeason + if title.ReleaseSeason != nil { + release_season = oapi.ReleaseSeason(*title.ReleaseSeason) + } + oapi_title.ReleaseSeason = &release_season + + oapi_status, err := TitleStatus2oapi(&title.TitleStatus) + if err != nil { + return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) + } + oapi_title.TitleStatus = oapi_status + + return oapi_title, nil +} + +func parseInt64(s string) (int64, error) { + i, err := strconv.ParseInt(s, 10, 64) + return i, err +} + +func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) { + var sqlc_status []sqlc.TitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.TitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTFinished) + case oapi.TitleStatusOngoing: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTOngoing) + case oapi.TitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil +} diff --git a/modules/backend/handlers/cursor.go b/modules/backend/handlers/cursor.go new file mode 100644 index 0000000..e3b48b0 --- /dev/null +++ b/modules/backend/handlers/cursor.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "strconv" +) + +// ParseCursorInto parses an opaque base64 cursor and injects values into target struct. +// +// Supported sort types: +// - "id" → sets CursorID (must be *int64) +// - "year" → sets CursorID (*int64) + CursorYear (*int32) +// - "rating" → sets CursorID (*int64) + CursorRating (*float64) +// +// Target struct may have any subset of these fields (e.g. only CursorID). +// Unknown fields are ignored. Missing fields → values are dropped (safe). +// +// Returns error if cursor is invalid or inconsistent with sort_by. +func ParseCursorInto(sortBy, cursorStr string, target any) error { + if cursorStr == "" { + return nil // no cursor → nothing to do + } + + // 1. Decode cursor + payload, err := decodeCursor(cursorStr) + if err != nil { + return err + } + + // 2. Extract ID (required for all types) + id, err := extractInt64(payload, "id") + if err != nil { + return fmt.Errorf("cursor: %v", err) + } + + // 3. Get reflect value of target (must be ptr to struct) + v := reflect.ValueOf(target) + if v.Kind() != reflect.Pointer || 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.Pointer { + 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]any, key string) (string, error) { + v, ok := m[key] + if !ok { + return "", fmt.Errorf("missing %q", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%q must be string", key) + } + return s, nil +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go new file mode 100644 index 0000000..c67177f --- /dev/null +++ b/modules/backend/handlers/titles.go @@ -0,0 +1,262 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + sqlc "nyanimedb/sql" + "strconv" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" +) + +func Word2Sqlc(s *string) *string { + if s == nil || *s == "" { + return nil + } + + return s +} + +func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { + if s == nil { + return nil, nil + } + var t oapi.TitleStatus + switch *s { + case sqlc.TitleStatusTFinished: + t = oapi.TitleStatusFinished + case sqlc.TitleStatusTOngoing: + t = oapi.TitleStatusOngoing + case sqlc.TitleStatusTPlanned: + t = oapi.TitleStatusPlanned + default: + return nil, fmt.Errorf("unexpected tittle status: %s", *s) + } + return &t, nil +} + +func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { + if s == nil { + return nil, nil + } + var t sqlc.ReleaseSeasonT + switch *s { + case oapi.Winter: + t = sqlc.ReleaseSeasonTWinter + case oapi.Spring: + t = sqlc.ReleaseSeasonTSpring + case oapi.Summer: + t = sqlc.ReleaseSeasonTSummer + case oapi.Fall: + t = sqlc.ReleaseSeasonTFall + default: + return nil, fmt.Errorf("unexpected release season: %s", *s) + } + return &t, nil +} + +func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { + + sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("query GetTitleTags: %v", err) + } + + oapi_tag_names := make(oapi.Tags, 1) + for _, title_tag := range sqlc_title_tags { + oapi_tag_name := make(map[string]string, 1) + err = json.Unmarshal(title_tag, &oapi_tag_name) + if err != nil { + return nil, fmt.Errorf("unmarshalling title_tag: %v", err) + } + oapi_tag_names = append(oapi_tag_names, oapi_tag_name) + } + + return oapi_tag_names, nil +} + +// func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { + +// var oapi_image oapi.Image + +// sqlc_image, err := s.db.GetImageByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil //todo: error reference in db +// } +// return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) +// } + +// //can cast and dont use brain cause all this fields required in image table +// oapi_image.Id = &sqlc_image.ID +// oapi_image.ImagePath = &sqlc_image.ImagePath +// storageTypeStr := string(sqlc_image.StorageType) +// oapi_image.StorageType = string(storageTypeStr) + +// return &oapi_image, nil +// } + +// func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { + +// var oapi_studio oapi.Studio + +// sqlc_studio, err := s.db.GetStudioByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil +// } +// return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) +// } + +// oapi_studio.Id = sqlc_studio.ID +// oapi_studio.Name = sqlc_studio.StudioName +// oapi_studio.Description = sqlc_studio.StudioDesc + +// if sqlc_studio.IllustID == nil { +// return &oapi_studio, nil +// } +// oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) +// if err != nil { +// return &oapi_studio, fmt.Errorf("GetImage: %v", err) +// } +// if oapi_illust != nil { +// oapi_studio.Poster = oapi_illust +// } + +// return &oapi_studio, nil +// } + +func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { + var oapi_title oapi.Title + + sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetTitlesTitleId204Response{}, nil + } + log.Errorf("%v", err) + return oapi.GetTitlesTitleId500Response{}, nil + } + + oapi_title, err = s.mapTitle(ctx, sqlc_title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitlesTitleId500Response{}, nil + } + + return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil +} + +func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { + opai_titles := make([]oapi.Title, 0) + + word := Word2Sqlc(request.Params.Word) + + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, err + } + + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, err + } + + params := sqlc.SearchTitlesParams{ + Word: word, + TitleStatuses: title_statuses, + Rating: request.Params.Rating, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Forward: true, // default + SortBy: "id", // default + Limit: request.Params.Limit, + } + + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } + if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) + if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, nil + } + } + } + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles500Response{}, nil + } + if len(titles) == 0 { + return oapi.GetTitles204Response{}, nil + } + + var new_cursor oapi.CursorObj + + for _, title := range titles { + + _title := sqlc.GetTitleByIDRow{ + ID: title.ID, + // StudioID: title.StudioID, + PosterID: title.PosterID, + TitleStatus: title.TitleStatus, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseYear: title.ReleaseYear, + ReleaseSeason: title.ReleaseSeason, + Season: title.Season, + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: title.TitleStorageType, + TitleImagePath: title.TitleImagePath, + TitleNames: title.TitleNames, + TagNames: title.TagNames, + StudioName: title.StudioName, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + // if title.TitleStorageType != nil { + // s := *title.TitleStorageType + // _title.TitleStorageType = string(s) + // } + + t, err := s.mapTitle(ctx, _title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles500Response{}, nil + } + opai_titles = append(opai_titles, t) + + new_cursor.Id = t.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := fmt.Sprint(*t.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) + new_cursor.Param = &tmp + } + } + } + + return oapi.GetTitles200JSONResponse{Cursor: new_cursor, Data: opai_titles}, nil +} diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index b67153d..1881f36 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -2,37 +2,50 @@ package handlers import ( "context" + "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" "strconv" + "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/oapi-codegen/runtime/types" + log "github.com/sirupsen/logrus" ) -type Server struct { - db *sqlc.Queries -} +// type Server struct { +// db *sqlc.Queries +// } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} -} +// func NewServer(db *sqlc.Queries) Server { +// return Server{db: db} +// } -func parseInt64(s string) (int32, error) { - i, err := strconv.ParseInt(s, 10, 64) - return int32(i), err -} +// func parseInt64(s string) (int32, error) { +// i, err := strconv.ParseInt(s, 10, 64) +// return int32(i), err +// } -func mapUser(u sqlc.GetUserByIDRow) oapi.User { +func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { + i := oapi.Image{ + Id: u.AvatarID, + ImagePath: u.ImagePath, + } + s, err := sql2StorageType(u.StorageType) + if err != nil { + return oapi.User{}, fmt.Errorf("mapUser, storage type: %v", err) + } + i.StorageType = s return oapi.User{ - AvatarId: u.AvatarID, - CreationDate: u.CreationDate, + Image: &i, + CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, - Mail: (*types.Email)(u.Mail), + Mail: StringToEmail(u.Mail), Nickname: u.Nickname, UserDesc: u.UserDesc, - } + }, nil } func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { @@ -40,12 +53,356 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque if err != nil { return oapi.GetUsersUserId404Response{}, nil } - user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + _user, err := s.db.GetUserByID(context.TODO(), userID) if err != nil { if err == pgx.ErrNoRows { return oapi.GetUsersUserId404Response{}, nil } return nil, err } - return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil + user, err := mapUser(_user) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserId500Response{}, err + } + return oapi.GetUsersUserId200JSONResponse(user), nil +} + +func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { + if p_date.Valid { + t := p_date.Time + return &t + } + return nil +} + +// func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { +// var sqlc_status SqlcUserStatus +// if s == nil { +// return &sqlc_status, nil +// } +// for _, t := range *s { +// switch t { +// case oapi.UserTitleStatusFinished: +// sqlc_status.finished = "finished" +// case oapi.UserTitleStatusDropped: +// sqlc_status.dropped = "dropped" +// case oapi.UserTitleStatusPlanned: +// sqlc_status.planned = "planned" +// case oapi.UserTitleStatusInProgress: +// sqlc_status.in_progress = "in-progress" +// default: +// return nil, fmt.Errorf("unexpected tittle status: %s", t) +// } +// } +// return &sqlc_status, nil +// } + +func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) { + var status oapi.UserTitleStatus + + switch s { + case sqlc.UsertitleStatusTFinished: + status = oapi.UserTitleStatusFinished + case sqlc.UsertitleStatusTDropped: + status = oapi.UserTitleStatusDropped + case sqlc.UsertitleStatusTPlanned: + status = oapi.UserTitleStatusPlanned + case sqlc.UsertitleStatusTInProgress: + status = oapi.UserTitleStatusInProgress + default: + return status, fmt.Errorf("unexpected tittle status: %s", s) + } + + return status, nil +} + +func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, error) { + var sqlc_status []sqlc.UsertitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.UserTitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTFinished) + case oapi.UserTitleStatusInProgress: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTInProgress) + case oapi.UserTitleStatusDropped: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTDropped) + case oapi.UserTitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil +} + +func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { + var sqlc_status sqlc.UsertitleStatusT = sqlc.UsertitleStatusTFinished + if s == nil { + return &sqlc_status, nil + } + + switch *s { + case oapi.UserTitleStatusFinished: + sqlc_status = sqlc.UsertitleStatusTFinished + case oapi.UserTitleStatusInProgress: + sqlc_status = sqlc.UsertitleStatusTInProgress + case oapi.UserTitleStatusDropped: + sqlc_status = sqlc.UsertitleStatusTDropped + case oapi.UserTitleStatusPlanned: + sqlc_status = sqlc.UsertitleStatusTPlanned + default: + return nil, fmt.Errorf("unexpected tittle status: %s", *s) + } + + return &sqlc_status, nil +} + +func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { + + oapi_usertitle := oapi.UserTitle{ + Ctime: &t.UserCtime, + Rate: t.UserRate, + ReviewId: t.ReviewID, + // Status: , + // Title: , + UserId: t.UserID, + } + + status, err := sql2usertitlestatus(t.UsertitleStatus) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Status = status + + _title := sqlc.GetTitleByIDRow{ + ID: t.ID, + // StudioID: title.StudioID, + PosterID: t.PosterID, + TitleStatus: t.TitleStatus, + Rating: t.Rating, + RatingCount: t.RatingCount, + ReleaseYear: t.ReleaseYear, + ReleaseSeason: t.ReleaseSeason, + Season: t.Season, + EpisodesAired: t.EpisodesAired, + EpisodesAll: t.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: t.TitleStorageType, + TitleImagePath: t.TitleImagePath, + StudioName: t.StudioName, + TitleNames: t.TitleNames, + TagNames: t.TagNames, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + oapi_title, err := s.mapTitle(ctx, _title) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Title = &oapi_title + + return oapi_usertitle, nil +} + +func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { + + oapi_usertitles := make([]oapi.UserTitle, 0) + + word := Word2Sqlc(request.Params.Word) + + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err + } + + // var statuses_sort []string + // if request.Params.Status != nil { + // for _, s := range *request.Params.Status { + // ss := string(s) // s type is alias for string + // statuses_sort = append(statuses_sort, ss) + // } + // } + + watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err + } + + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err + } + + userID, err := parseInt64(request.UserId) + if err != nil { + log.Errorf("get user titles: %v", err) + return oapi.GetUsersUserIdTitles404Response{}, err + } + params := sqlc.SearchUserTitlesParams{ + UserID: userID, + Word: word, + TitleStatuses: title_statuses, + UsertitleStatuses: watch_status, + Rating: request.Params.Rating, + Rate: request.Params.MyRate, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Forward: true, // default + SortBy: "id", // default + Limit: request.Params.Limit, + } + + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } + if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) + if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + 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 + } + + var new_cursor oapi.CursorObj + + for _, title := range titles { + + t, err := s.mapUsertitle(ctx, title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles500Response{}, nil + } + oapi_usertitles = append(oapi_usertitles, t) + + new_cursor.Id = t.Title.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := fmt.Sprint(*t.Title.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) // падает + new_cursor.Param = &tmp + } + } + } + + return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil +} + +func EmailToStringPtr(e *types.Email) *string { + if e == nil { + return nil + } + s := string(*e) + return &s +} + +func StringToEmail(e *string) *types.Email { + if e == nil { + return nil + } + s := types.Email(*e) + return &s +} + +// UpdateUser implements oapi.StrictServerInterface. +func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestObject) (oapi.UpdateUserResponseObject, error) { + + params := sqlc.UpdateUserParams{ + AvatarID: request.Body.AvatarId, + DispName: request.Body.DispName, + UserDesc: request.Body.UserDesc, + Mail: EmailToStringPtr(request.Body.Mail), + UserID: request.UserId, + } + + user, err := s.db.UpdateUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUser500Response{}, nil + } + + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + // AvatarId: user.AvatarID, + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + + return oapi.UpdateUser200JSONResponse(oapi_user), nil +} + +func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { + //TODO: add review if exists + status, err := UserTitleStatus2Sqlc1(&request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle400Response{}, nil + } + + params := sqlc.InsertUserTitleParams{ + UserID: request.UserId, + TitleID: request.Body.TitleId, + Status: *status, + Rate: request.Body.Rate, + ReviewID: request.Body.ReviewId, + } + + user_title, err := s.db.InsertUserTitle(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + oapi_usertitle := struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status oapi.UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + }{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil } diff --git a/modules/backend/main.go b/modules/backend/main.go index 42a66d3..3ac6603 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -13,7 +13,7 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/pelletier/go-toml/v2" ) @@ -31,17 +31,17 @@ func main() { // log.Fatalf("Failed to init config: %v\n", err) // } - conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL")) + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) } - defer conn.Close(context.Background()) + defer pool.Close() r := gin.Default() - queries := sqlc.New(conn) + queries := sqlc.New(pool) server := handlers.NewServer(queries) // r.LoadHTMLGlob("templates/*") diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b1dd8af..0146b25 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,7 +1,7 @@ -- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = $1; +WHERE id = sqlc.arg('illust_id')::bigint; -- name: CreateImage :one INSERT INTO images (storage_type, image_path) @@ -9,9 +9,53 @@ VALUES ($1, $2) RETURNING id, storage_type, image_path; -- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1; +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE t.id = sqlc.arg('id')::bigint; + + +-- name: GetStudioByID :one +SELECT * +FROM studios +WHERE id = sqlc.arg('studio_id')::bigint; + +-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + sqlc.arg('studio_name')::text, + sqlc.narg('illust_id')::bigint, + sqlc.narg('studio_desc')::text) +RETURNING id, studio_name, illust_id, studio_desc; + +-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = sqlc.arg('title_id')::bigint; + +-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + sqlc.arg('title_id')::bigint, + sqlc.arg('tag_id')::bigint) +RETURNING title_id, tag_id; + +-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + sqlc.arg('tag_names')::jsonb) +RETURNING id, tag_names; -- -- name: ListUsers :many -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date @@ -24,26 +68,315 @@ WHERE id = $1; -- VALUES ($1, $2, $3, $4, $5, $6, $7) -- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; --- -- name: UpdateUser :one --- UPDATE users --- SET --- avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), --- disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), --- user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), --- passhash = COALESCE(sqlc.narg('passhash'), passhash) --- WHERE user_id = sqlc.arg('user_id') --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), + disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), + user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), + mail = COALESCE(sqlc.narg('mail'), mail) +WHERE id = sqlc.arg('user_id') +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- -- name: DeleteUser :exec -- DELETE FROM users -- WHERE user_id = $1; --- -- name: GetTitleByID :one --- SELECT title_id, title_names, studio_id, poster_id, signal_ids, --- title_status, rating, rating_count, release_year, release_season, --- season, episodes_aired, episodes_all, episodes_len --- FROM titles --- WHERE title_id = $1; +-- name: GetTitleByID :one +-- sqlc.struct: TitlesFull +SELECT + t.*, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = sqlc.arg('title_id')::bigint +GROUP BY + t.id, i.id, s.id, si.id; + +-- name: SearchTitles :many +SELECT + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name + +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END + END + + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + + AND ( + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) + ) + AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + +GROUP BY + t.id, i.id, s.id + +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + END + END ASC, + CASE WHEN NOT sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + END + END DESC, + + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit + +-- name: SearchUserTitles :many +SELECT + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name + +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + u.user_id = sqlc.arg('user_id')::bigint + AND + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END + END + + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + + AND ( + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) + ) + AND ( + sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0 + OR u.status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) + ) + AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) + AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + +GROUP BY + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + 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 -- -- name: ListTitles :many -- SELECT title_id, title_names, studio_id, poster_id, signal_ids, @@ -69,10 +402,10 @@ WHERE id = $1; -- WHERE title_id = sqlc.arg('title_id') -- RETURNING *; --- -- name: GetReviewByID :one --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE review_id = $1; +-- name: GetReviewByID :one +SELECT * +FROM reviews +WHERE review_id = sqlc.arg('review_id')::bigint; -- -- name: CreateReview :one -- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) @@ -91,7 +424,7 @@ WHERE id = $1; -- DELETE FROM reviews -- WHERE review_id = $1; --- -- name: ListReviewsByTitle :many +-- -- name: ListReviewsByTitle :many -- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -- FROM reviews -- WHERE title_id = $1 @@ -117,10 +450,16 @@ WHERE id = $1; -- ORDER BY usertitle_id -- LIMIT $2 OFFSET $3; --- -- name: CreateUserTitle :one --- INSERT INTO usertitles (user_id, title_id, status, rate, review_id) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING usertitle_id, user_id, title_id, status, rate, review_id; +-- name: InsertUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + sqlc.arg('user_id')::bigint, + sqlc.arg('title_id')::bigint, + sqlc.arg('status')::usertitle_status_t, + sqlc.narg('rate')::int, + sqlc.narg('review_id')::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime; -- -- name: UpdateUserTitle :one -- UPDATE usertitles diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index a538968..c3a851f 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -19,6 +19,15 @@ server { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } + location /auth/ { + rewrite ^/auth/(.*)$ /$1 break; + proxy_pass http://nyanimedb-auth:8082/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } #error_page 404 /404.html; error_page 500 502 503 504 /50x.html; diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index 6a06afb..40bb520 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,10 +8,14 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@headlessui/react": "^2.2.9", + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -27,6 +31,9 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -337,7 +344,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -354,7 +360,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -371,7 +376,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -388,7 +392,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -405,7 +408,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -422,7 +424,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -439,7 +440,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -456,7 +456,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -473,7 +472,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -490,7 +488,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -507,7 +504,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -524,7 +520,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -541,7 +536,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -558,7 +552,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -575,7 +568,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -592,7 +584,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -609,7 +600,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,7 +616,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,7 +632,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -660,7 +648,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -677,7 +664,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -694,7 +680,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -711,7 +696,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,7 +712,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -745,7 +728,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -762,7 +744,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -929,6 +910,88 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -985,7 +1048,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -996,7 +1058,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1007,7 +1068,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1017,14 +1077,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1076,6 +1134,103 @@ "node": ">= 8" } }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -1090,7 +1245,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1104,7 +1258,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1118,7 +1271,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1132,7 +1284,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1146,7 +1297,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1160,7 +1310,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1174,7 +1323,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1188,7 +1336,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1202,7 +1349,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1216,7 +1362,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1230,7 +1375,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1244,7 +1388,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1258,7 +1401,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1272,7 +1414,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1286,7 +1427,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1300,7 +1440,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1314,7 +1453,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1328,7 +1466,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1342,7 +1479,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1356,7 +1492,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1370,7 +1505,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1384,13 +1518,305 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1440,7 +1866,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1454,7 +1879,7 @@ "version": "24.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2006,6 +2431,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2127,6 +2561,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2148,6 +2591,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2197,7 +2653,6 @@ "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2617,7 +3072,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2726,7 +3180,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -2884,6 +3337,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2988,6 +3450,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3021,6 +3732,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3109,7 +3829,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3249,7 +3968,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3269,7 +3987,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3437,7 +4154,6 @@ "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3558,7 +4274,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3590,11 +4305,35 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3611,7 +4350,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3629,7 +4367,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -3665,6 +4402,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3735,7 +4478,7 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -3789,11 +4532,19 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3869,7 +4620,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3887,7 +4637,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { diff --git a/modules/frontend/package.json b/modules/frontend/package.json index b4977aa..e0b65ba 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,10 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^2.2.9", + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -29,5 +33,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } } diff --git a/modules/frontend/src/App.css b/modules/frontend/src/App.css index b9d355d..e69de29 100644 --- a/modules/frontend/src/App.css +++ b/modules/frontend/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index a88ad57..e2c909f 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,15 +1,37 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UserPage from "./components/UserPage/UserPage"; +import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; +import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import TitlePage from "./pages/TitlePage/TitlePage"; +import { LoginPage } from "./pages/LoginPage/LoginPage"; +import { Header } from "./components/Header/Header"; const App: React.FC = () => { + // Получаем username из localStorage + const username = localStorage.getItem("username") || undefined; + const userId = localStorage.getItem("userId"); + return ( +
- } /> + } /> + } /> + + {/* /profile рендерит UsersIdPage с id из localStorage */} + : } + /> + + } /> + + } /> + } /> ); }; + export default App; \ No newline at end of file diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 185e5c3..6ce873e 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', + BASE: 'http://10.1.0.65:8081/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index e4f4ef4..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,10 +7,22 @@ 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 { CursorObj } from './models/CursorObj'; +export type { Image } from './models/Image'; +export type { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; +export type { StorageType } from './models/StorageType'; +export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; +export type { Tags } from './models/Tags'; export type { Title } from './models/Title'; +export type { title_sort } from './models/title_sort'; +export type { TitleSort } from './models/TitleSort'; +export type { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; +export type { UserTitleMini } from './models/UserTitleMini'; +export type { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/CursorObj.ts b/modules/frontend/src/api/models/CursorObj.ts new file mode 100644 index 0000000..f54abb1 --- /dev/null +++ b/modules/frontend/src/api/models/CursorObj.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CursorObj = { + id: number; + param?: string; +}; + diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts new file mode 100644 index 0000000..887bf2f --- /dev/null +++ b/modules/frontend/src/api/models/Image.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { StorageType } from './StorageType'; +export type Image = { + id?: number; + storage_type?: StorageType; + image_path?: string; +}; + diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts new file mode 100644 index 0000000..ad9f930 --- /dev/null +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title release season + */ +export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts new file mode 100644 index 0000000..f6d086b --- /dev/null +++ b/modules/frontend/src/api/models/StorageType.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Studio.ts b/modules/frontend/src/api/models/Studio.ts new file mode 100644 index 0000000..062695a --- /dev/null +++ b/modules/frontend/src/api/models/Studio.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Image } from './Image'; +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts index 9560ea8..665c724 100644 --- a/modules/frontend/src/api/models/Tag.ts +++ b/modules/frontend/src/api/models/Tag.ts @@ -2,4 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Tag = Record; +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = Record; diff --git a/modules/frontend/src/api/models/Tags.ts b/modules/frontend/src/api/models/Tags.ts new file mode 100644 index 0000000..748f066 --- /dev/null +++ b/modules/frontend/src/api/models/Tags.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Tag } from './Tag'; +/** + * Array of localized tags + */ +export type Tags = Array; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts index 4da7aa3..9ffdeb6 100644 --- a/modules/frontend/src/api/models/Title.ts +++ b/modules/frontend/src/api/models/Title.ts @@ -2,4 +2,30 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Title = Record; +import type { Image } from './Image'; +import type { ReleaseSeason } from './ReleaseSeason'; +import type { Studio } from './Studio'; +import type { Tags } from './Tags'; +import type { TitleStatus } from './TitleStatus'; +export type Title = { + /** + * Unique title ID (primary key) + */ + id: number; + /** + * Localized titles. Key = language (ISO 639-1), value = list of names + */ + title_names: Record>; + studio?: Studio; + tags: Tags; + poster?: Image; + title_status?: TitleStatus; + rating?: number; + rating_count?: number; + release_year?: number; + release_season?: ReleaseSeason; + episodes_aired?: number; + episodes_all?: number; + episodes_len?: Record; +}; + diff --git a/modules/frontend/src/api/models/TitleSort.ts b/modules/frontend/src/api/models/TitleSort.ts new file mode 100644 index 0000000..1c9385e --- /dev/null +++ b/modules/frontend/src/api/models/TitleSort.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title sort order + */ +export type TitleSort = 'id' | 'year' | 'rating' | 'views'; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts new file mode 100644 index 0000000..72e0261 --- /dev/null +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title status + */ +export type TitleStatus = 'finished' | 'ongoing' | 'planned'; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index b03d22f..969023f 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -2,15 +2,13 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { Image } from './Image'; export type User = { /** * Unique user ID (primary key) */ id?: number; - /** - * ID of the user avatar (references images table) - */ - avatar_id?: number | null; + image?: Image; /** * User email */ @@ -30,6 +28,6 @@ export type User = { /** * Timestamp when the user was created */ - creation_date: string; + creation_date?: string; }; diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts index 26d5ddc..42b7919 100644 --- a/modules/frontend/src/api/models/UserTitle.ts +++ b/modules/frontend/src/api/models/UserTitle.ts @@ -2,4 +2,14 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type UserTitle = Record; +import type { Title } from './Title'; +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitle = { + user_id: number; + title?: Title; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/models/UserTitleMini.ts b/modules/frontend/src/api/models/UserTitleMini.ts new file mode 100644 index 0000000..2b223ce --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleMini.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts new file mode 100644 index 0000000..0a29626 --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User's title status + */ +export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; diff --git a/modules/frontend/src/api/models/cursor.ts b/modules/frontend/src/api/models/cursor.ts new file mode 100644 index 0000000..5788e14 --- /dev/null +++ b/modules/frontend/src/api/models/cursor.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type cursor = string; diff --git a/modules/frontend/src/api/models/title_sort.ts b/modules/frontend/src/api/models/title_sort.ts new file mode 100644 index 0000000..69b01a7 --- /dev/null +++ b/modules/frontend/src/api/models/title_sort.ts @@ -0,0 +1,6 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { TitleSort } from './TitleSort'; +export type title_sort = TitleSort; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 7ebd129..5070fae 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -2,11 +2,103 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { CursorObj } from '../models/CursorObj'; +import type { ReleaseSeason } from '../models/ReleaseSeason'; +import type { Title } from '../models/Title'; +import type { TitleSort } from '../models/TitleSort'; +import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; +import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleMini } from '../models/UserTitleMini'; +import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class DefaultService { + /** + * Get titles + * @param cursor + * @param sort + * @param sortForward + * @param word + * @param status List of title statuses to filter + * @param rating + * @param releaseYear + * @param releaseSeason + * @param limit + * @param offset + * @param fields + * @returns any List of titles with cursor + * @throws ApiError + */ + public static getTitles( + cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, + word?: string, + status?: Array, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<{ + /** + * List of titles + */ + data: Array; + cursor: CursorObj; + }> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles', + query: { + 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, + '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 getTitle( + 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 @@ -14,7 +106,7 @@ export class DefaultService { * @returns User User info * @throws ApiError */ - public static getUsers( + public static getUsersId( userId: string, fields: string = 'all', ): CancelablePromise<User> { @@ -28,7 +120,194 @@ export class DefaultService { 'fields': fields, }, errors: { + 400: `Request params are not correct`, 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Partially update a user account + * Update selected user profile fields (excluding password). + * Password updates must be done via the dedicated auth-service (`/auth/`). + * Fields not provided in the request body remain unchanged. + * + * @param userId User ID (primary key) + * @param requestBody + * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). + * @throws ApiError + */ + public static updateUser( + userId: number, + requestBody: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }, + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, + 401: `Unauthorized — missing or invalid authentication token`, + 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, + 404: `User not found`, + 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, + 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user titles + * @param userId + * @param cursor + * @param sort + * @param sortForward + * @param word + * @param status List of title statuses to filter + * @param watchStatus + * @param rating + * @param myRate + * @param releaseYear + * @param releaseSeason + * @param limit + * @param fields + * @returns any List of user titles + * @throws ApiError + */ + public static getUsersTitles( + userId: string, + cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, + word?: string, + status?: Array<TitleStatus>, + watchStatus?: Array<UserTitleStatus>, + rating?: number, + myRate?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + fields: string = 'all', + ): CancelablePromise<{ + data: Array<UserTitle>; + cursor: CursorObj; + }> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, + 'word': word, + 'status': status, + 'watch_status': watchStatus, + 'rating': rating, + 'my_rate': myRate, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Add a title to a user + * User adding title to list af watched, status required + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns UserTitleMini Title successfully added to user + * @throws ApiError + */ + public static addUserTitle( + userId: number, + requestBody: { + title_id: number; + status: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'POST', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to assign titles to this user`, + 404: `User or Title not found`, + 409: `Conflict — title already assigned to user (if applicable)`, + 500: `Internal server error`, + }, + }); + } + /** + * Update a usertitle + * User updating title list of watched + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns UserTitleMini Title successfully updated + * @throws ApiError + */ + public static updateUserTitle( + userId: number, + requestBody: { + title_id: number; + status?: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to update title`, + 404: `User or Title not found`, + 500: `Internal server error`, }, }); } diff --git a/modules/frontend/src/auth/core/ApiError.ts b/modules/frontend/src/auth/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/auth/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/auth/core/ApiRequestOptions.ts b/modules/frontend/src/auth/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/auth/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record<string, any>; + readonly cookies?: Record<string, any>; + readonly headers?: Record<string, any>; + readonly query?: Record<string, any>; + readonly formData?: Record<string, any>; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record<number, string>; +}; diff --git a/modules/frontend/src/auth/core/ApiResult.ts b/modules/frontend/src/auth/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/auth/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/auth/core/CancelablePromise.ts b/modules/frontend/src/auth/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/auth/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise<T> implements Promise<T> { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise<T>; + #resolve?: (value: T | PromiseLike<T>) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike<T>) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise<T>((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike<T>): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then<TResult1 = T, TResult2 = never>( + onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null + ): Promise<TResult1 | TResult2> { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch<TResult = never>( + onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null + ): Promise<T | TResult> { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise<T> { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts new file mode 100644 index 0000000..79aa305 --- /dev/null +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; +type Headers = Record<string, string>; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver<string> | undefined; + USERNAME?: string | Resolver<string> | undefined; + PASSWORD?: string | Resolver<string> | undefined; + HEADERS?: Headers | Resolver<Headers> | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'http://10.1.0.65:8081/auth', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/auth/core/request.ts b/modules/frontend/src/auth/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/auth/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record<string, any>): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; + +export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { + if (typeof resolver === 'function') { + return (resolver as Resolver<T>)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record<string, string>); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async <T>( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record<string, string>, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise<AxiosResponse<T>> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError<T>; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse<any>): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record<number, string> = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise<T> + * @throws ApiError + */ +export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/auth/index.ts b/modules/frontend/src/auth/index.ts new file mode 100644 index 0000000..b0989c4 --- /dev/null +++ b/modules/frontend/src/auth/index.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export { AuthService } from './services/AuthService'; diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts new file mode 100644 index 0000000..94578d8 --- /dev/null +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -0,0 +1,58 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class AuthService { + /** + * Sign up a new user + * @param requestBody + * @returns any Sign-up result + * @throws ApiError + */ + public static postAuthSignUp( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string | null; + user_id?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-up', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Sign in a user and return JWT + * @param requestBody + * @returns any Sign-in result with JWT + * @throws ApiError + */ + public static postAuthSignIn( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + error?: string | null; + user_id?: string | null; + user_name?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-in', + body: requestBody, + mediaType: 'application/json', + errors: { + 401: `Access denied due to invalid credentials`, + }, + }); + } +} diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx new file mode 100644 index 0000000..26f1658 --- /dev/null +++ b/modules/frontend/src/components/Header/Header.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; + +type HeaderProps = { + username?: string; +}; + +export const Header: React.FC<HeaderProps> = ({ username }) => { + const [menuOpen, setMenuOpen] = useState(false); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + return ( + <header className="w-full bg-white shadow-md sticky top-0 left-0 z-50"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex justify-between h-16 items-center"> + + {/* Левый блок — логотип / название */} + <div className="flex-shrink-0"> + <Link to="/" className="text-xl font-bold text-gray-800 hover:text-blue-600"> + NyanimeDB + </Link> + </div> + + {/* Центр — ссылки на разделы (desktop) */} + <nav className="hidden md:flex space-x-4"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600"> + Titles + </Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600"> + Users + </Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600"> + About + </Link> + </nav> + + {/* Правый блок — профиль */} + <div className="hidden md:flex items-center space-x-4"> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium"> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium"> + Login + </Link> + )} + </div> + + {/* Бургер для мобильного */} + <div className="md:hidden flex items-center"> + <button + onClick={toggleMenu} + className="p-2 rounded-md hover:bg-gray-200 transition" + > + {menuOpen ? ( + <XMarkIcon className="w-6 h-6 text-gray-800" /> + ) : ( + <Bars3Icon className="w-6 h-6 text-gray-800" /> + )} + </button> + </div> + + </div> + </div> + + {/* Мобильное меню */} + {menuOpen && ( + <div className="md:hidden bg-white border-t border-gray-200 shadow-md"> + <nav className="flex flex-col p-4 space-y-2"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Titles</Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Users</Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>About</Link> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + Login + </Link> + )} + </nav> + </div> + )} + </header> + ); +}; diff --git a/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx new file mode 100644 index 0000000..679feea --- /dev/null +++ b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; + +export type LayoutSwitchProps = { + layout: "square" | "horizontal" + setLayout: (value: React.SetStateAction<"square" | "horizontal">) => void +}; + +export function LayoutSwitch({ + layout, + setLayout +}: LayoutSwitchProps) { + + return ( + <div className="flex justify-end"> + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" + ? <Squares2X2Icon className="w-6 h-6" /> + : <Bars3Icon className="w-6 h-6" /> + } + </button> + </div> + ); +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx new file mode 100644 index 0000000..ea6d683 --- /dev/null +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +export type ListViewProps<T> = { + items: T[]; + layout: "square" | "horizontal"; + renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; + onLoadMore: () => void; + hasMore: boolean; + loadingMore: boolean; +}; + +export function ListView<T>({ + items, + layout, + renderItem, + onLoadMore, + hasMore, + loadingMore +}: ListViewProps<T>) { + + return ( + <div className="w-full flex flex-col items-center"> + {/* Items */} + <div + className={`w-full sm:w-4/5 grid gap-6 ${ + layout === "square" + ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + : "grid-cols-1" + }`} + > + {items.map(item => renderItem(item, layout))} + </div> + + {/* Load More */} + {hasMore && ( + <div className="mt-8"> + <button + className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50" + disabled={loadingMore} + onClick={onLoadMore} + > + {loadingMore ? "Loading..." : "Load More"} + </button> + </div> + )} + </div> + ); +} diff --git a/modules/frontend/src/components/SearchBar/SearchBar.tsx b/modules/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..87aee66 --- /dev/null +++ b/modules/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,34 @@ +type SearchBarProps = { + placeholder?: string; + search: string; + setSearch: (value: string) => void; +}; + +export function SearchBar({ + placeholder = "Search...", + search, + setSearch, +}: SearchBarProps) { + return ( + <div className="w-full"> + <input + type="text" + value={search} + placeholder={placeholder} + onChange={(e) => setSearch(e.target.value)} + className=" + w-full + px-4 + py-2 + border + border-gray-300 + rounded-lg + focus:outline-none + focus:ring-2 + focus:ring-blue-500 + text-black + " + /> + </div> + ); +} diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx new file mode 100644 index 0000000..ddafd34 --- /dev/null +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { TitleSort } from "../../api"; +import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; + +type TitlesSortBoxProps = { + sort: TitleSort; + setSort: (value: TitleSort) => void; + sortForward: boolean; + setSortForward: (value: boolean) => void; +}; + +const SORT_OPTIONS: TitleSort[] = ["id", "rating", "year", "views"]; + +export function TitlesSortBox({ + sort, + setSort, + sortForward, + setSortForward, +}: TitlesSortBoxProps) { + const [open, setOpen] = useState(false); + + const toggleSortDirection = () => setSortForward(!sortForward); + const handleSortSelect = (newSort: TitleSort) => { + setSort(newSort); + setOpen(false); + }; + + return ( + <div className="inline-flex relative z-50"> + {/* Левая часть — смена направления */} + <button + onClick={toggleSortDirection} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-l-lg transition" + > + {sortForward ? <ArrowUpIcon className="w-4 h-4 mr-1" /> : <ArrowDownIcon className="w-4 h-4 mr-1" />} + <span className="text-sm font-medium">Order</span> + </button> + + {/* Правая часть — выбор параметра */} + <button + onClick={() => setOpen(!open)} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 border-l-0 rounded-r-lg transition" + > + <span className="text-sm font-medium">{sort}</span> + <ChevronDownIcon className="w-4 h-4 ml-1" /> + </button> + + {/* Dropdown */} + {open && ( + <ul className="absolute top-full left-0 mt-1 w-40 bg-white border border-gray-300 rounded-md shadow-lg z-[1000]"> + {SORT_OPTIONS.map(option => ( + <li key={option}> + <button + className={`w-full text-left px-4 py-2 hover:bg-gray-100 transition ${ + option === sort ? "font-bold bg-gray-100" : "" + }`} + onClick={() => handleSortSelect(option)} + > + {option} + </button> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/modules/frontend/src/components/UserPage/UserPage.module.css b/modules/frontend/src/components/UserPage/UserPage.module.css deleted file mode 100644 index 75195bf..0000000 --- a/modules/frontend/src/components/UserPage/UserPage.module.css +++ /dev/null @@ -1,97 +0,0 @@ -.container { - display: flex; - justify-content: center; - align-items: flex-start; - padding: 3rem 1rem; - background-color: #f5f6fa; - min-height: 100vh; - font-family: "Inter", sans-serif; -} - -.card { - background-color: #ffffff; - border-radius: 1rem; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); - padding: 2rem; - max-width: 400px; - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.avatar { - margin-bottom: 1.5rem; -} - -.avatarImg { - width: 120px; - height: 120px; - border-radius: 50%; - object-fit: cover; - border: 3px solid #4a90e2; -} - -.avatarPlaceholder { - width: 120px; - height: 120px; - border-radius: 50%; - background-color: #dcdde1; - display: flex; - align-items: center; - justify-content: center; - font-size: 3rem; - color: #4a4a4a; - font-weight: bold; - border: 3px solid #4a90e2; -} - -.info { - display: flex; - flex-direction: column; - align-items: center; -} - -.name { - font-size: 1.8rem; - font-weight: 700; - margin: 0.25rem 0; - color: #2f3640; -} - -.nickname { - font-size: 1rem; - color: #718093; - margin-bottom: 1rem; -} - -.desc { - font-size: 1rem; - color: #353b48; - margin-bottom: 1rem; -} - -.created { - font-size: 0.9rem; - color: #7f8fa6; -} - -.loader { - display: flex; - justify-content: center; - align-items: center; - height: 80vh; - font-size: 1.5rem; - color: #4a90e2; -} - -.error { - display: flex; - justify-content: center; - align-items: center; - height: 80vh; - color: #e84118; - font-weight: 600; - font-size: 1.2rem; -} diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx new file mode 100644 index 0000000..cde6037 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { Title } from "../../api/models/Title"; + +export function TitleCardHorizontal({ title }: { title: Title }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={80} /> + )} + <div> + <h3>{title.title_names["en"]}</h3> + <p>{title.release_year} · {title.release_season} · Rating: {title.rating}</p> + <p>Status: {title.title_status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx new file mode 100644 index 0000000..e21c258 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -0,0 +1,22 @@ +// TitleCardSquare.tsx +import type { Title } from "../../api/models/Title"; + +export function TitleCardSquare({ title }: { title: Title }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={140} /> + )} + <div> + <h4>{title.title_names["en"]}</h4> + <small>{title.release_year} • {title.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx new file mode 100644 index 0000000..ad7d5df --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardHorizontal({ title }: { title: UserTitle }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={80} /> + )} + <div> + <h3>{title.title?.title_names["en"]}</h3> + <p>{title.title?.release_year} · {title.title?.release_season} · Rating: {title.title?.rating}</p> + <p>Status: {title.title?.title_status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx new file mode 100644 index 0000000..edcf1d5 --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardSquare({ title }: { title: UserTitle }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={140} /> + )} + <div> + <h4>{title.title?.title_names["en"]}</h4> + <h5>{title.status}</h5> + <small>{title.title?.release_year} • {title.title?.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index 08a3ac9..7b32a9b 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -1,68 +1,9 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@import "tailwindcss"; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { +html, body, #root { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} + padding: 0; + width: 100%; + height: 100%; + @apply text-black bg-white; +} \ No newline at end of file diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..89ee88c --- /dev/null +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,118 @@ +import React, { useState } from "react"; +import { AuthService } from "../../auth/services/AuthService"; +import { useNavigate } from "react-router-dom"; + +export const LoginPage: React.FC = () => { + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(true); // true = login, false = signup + const [nickname, setNickname] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + if (isLogin) { + const res = await AuthService.postAuthSignIn({ nickname, pass: password }); + if (res.user_id && res.user_name) { + // Сохраняем user_id и username в localStorage + localStorage.setItem("userId", res.user_id); + localStorage.setItem("username", res.user_name); + + navigate("/profile"); // редирект на профиль + } else { + setError(res.error || "Login failed"); + } + } else { + // SignUp оставляем без сохранения данных + const res = await AuthService.postAuthSignUp({ nickname, pass: password }); + if (res.user_id) { + setIsLogin(true); // переключаемся на login после регистрации + } else { + setError(res.error || "Sign up failed"); + } + } + } catch (err: any) { + console.error(err); + setError(err?.message || "Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> + <div className="max-w-md w-full bg-white shadow-md rounded-lg p-8"> + <h2 className="text-2xl font-bold mb-6 text-center"> + {isLogin ? "Login" : "Sign Up"} + </h2> + + {error && <div className="text-red-600 mb-4">{error}</div>} + + <form onSubmit={handleSubmit} className="space-y-4"> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1"> + Nickname + </label> + <input + type="text" + value={nickname} + onChange={(e) => setNickname(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> + </div> + + <div> + <label className="block text-sm font-medium text-gray-700 mb-1"> + Password + </label> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> + </div> + + <button + type="submit" + disabled={loading} + className="w-full bg-blue-600 text-white py-2 rounded-lg font-semibold hover:bg-blue-700 transition disabled:opacity-50" + > + {loading ? "Please wait..." : isLogin ? "Login" : "Sign Up"} + </button> + </form> + + <div className="mt-4 text-center text-sm text-gray-600"> + {isLogin ? ( + <> + Don't have an account?{" "} + <button + onClick={() => setIsLogin(false)} + className="text-blue-600 hover:underline" + > + Sign Up + </button> + </> + ) : ( + <> + Already have an account?{" "} + <button + onClick={() => setIsLogin(true)} + className="text-blue-600 hover:underline" + > + Login + </button> + </> + )} + </div> + </div> + </div> + ); +}; diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx new file mode 100644 index 0000000..5ea0e3d --- /dev/null +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title, UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" }, + { status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" }, + { status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" }, + { status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" }, +]; + +export default function TitlePage() { + const params = useParams(); + const titleId = Number(params.id); + + const [title, setTitle] = useState<Title | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null); + const [updatingStatus, setUpdatingStatus] = useState(false); + + useEffect(() => { + const fetchTitle = async () => { + setLoading(true); + try { + const data = await DefaultService.getTitle(titleId, "all"); + setTitle(data); + setError(null); + } catch (err: any) { + console.error(err); + setError(err?.message || "Failed to fetch title"); + } finally { + setLoading(false); + } + }; + fetchTitle(); + }, [titleId]); + + const handleStatusClick = async (status: UserTitleStatus) => { + if (updatingStatus || userStatus === status) return; + + const userId = Number(localStorage.getItem("userId")); + if (!userId) { + alert("You must be logged in to set status."); + return; + } + + setUpdatingStatus(true); + try { + await DefaultService.addUserTitle(userId, { + title_id: titleId, + status, + }); + setUserStatus(status); + } catch (err: any) { + console.error(err); + alert(err?.message || "Failed to set status"); + } finally { + setUpdatingStatus(false); + } + }; + + const getTagsString = () => + title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); + + if (loading) return <div className="mt-20 font-medium text-black">Loading title...</div>; + if (error) return <div className="mt-20 text-red-600 font-medium">{error}</div>; + if (!title) return null; + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> + <div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> + {/* Постер */} + <div className="flex flex-col items-center"> + <img + src={title.poster?.image_path || "/default-poster.png"} + alt={title.title_names?.en?.[0] || "Title poster"} + className="w-48 h-72 object-cover rounded-lg mb-4" + /> + + {/* Статус кнопки с иконками */} + <div className="flex gap-2 mt-2 flex-wrap justify-center"> + {STATUS_BUTTONS.map(btn => ( + <button + key={btn.status} + onClick={() => handleStatusClick(btn.status)} + disabled={updatingStatus} + className={`p-2 rounded-lg transition flex items-center justify-center ${ + userStatus === btn.status + ? "bg-blue-600 text-white" + : "bg-gray-200 text-gray-700 hover:bg-gray-300" + }`} + title={btn.label} + > + {btn.icon} + </button> + ))} + </div> + </div> + + {/* Информация о тайтле */} + <div className="flex-1 flex flex-col"> + <h1 className="text-3xl font-bold mb-2"> + {title.title_names?.en?.[0] || "Untitled"} + </h1> + {title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>} + {title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>} + {title.rating !== undefined && ( + <p className="text-gray-700 mb-1"> + Rating: {title.rating} ({title.rating_count} votes) + </p> + )} + {title.release_year && ( + <p className="text-gray-700 mb-1"> + Released: {title.release_year} {title.release_season || ""} + </p> + )} + {title.episodes_aired !== undefined && ( + <p className="text-gray-700 mb-1"> + Episodes: {title.episodes_aired}/{title.episodes_all} + </p> + )} + {title.tags && title.tags.length > 0 && ( + <p className="text-gray-700 mb-1"> + Tags: {getTagsString()} + </p> + )} + </div> + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx new file mode 100644 index 0000000..0fec3c8 --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from "react"; +import { ListView } from "../../components/ListView/ListView"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { DefaultService } from "../../api/services/DefaultService"; +import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; +import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import type { CursorObj, Title, TitleSort } from "../../api"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; + +const PAGE_SIZE = 10; + +export default function TitlesPage() { + const [titles, setTitles] = useState<Title[]>([]); + const [nextPage, setNextPage] = useState<Title[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + const fetchPage = async (cursorObj: CursorObj | null) => { + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; + + try { + const result = await DefaultService.getTitles( + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, + undefined, + undefined, + undefined, + PAGE_SIZE, + undefined, + "all" + ); + + if ((result === undefined) || !result.data?.length) { + return { items: [], nextCursor: null }; + } + return { + items: result.data ?? [], + nextCursor: result.cursor ?? null + }; + } catch (err: any) { + if (err.status === 204) { + return { items: [], nextCursor: null }; + } + throw err; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoading(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoading(false); + }; + + initLoad(); + }, [search, sort, sortForward]); + + +const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + // Подгружаем следующую страницу с сервера + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) { + setNextPage(next.items); + } + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + // Любой сценарий – выключаем loadingMore + setLoadingMore(false); +}; + + + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center text-black">Titles</h1> + + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox + sort={sort} + setSort={setSort} + sortForward={sortForward} + setSortForward={setSortForward} + /> + </div> + + {loading && <div className="mt-20 font-medium text-black">Loading...</div>} + + {!loading && titles.length === 0 && ( + <div className="mt-20 font-medium text-black">No titles found.</div> + )} + + {titles.length > 0 && ( + <> + <ListView<Title> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length == 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} + </div> + ); +} diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css new file mode 100644 index 0000000..7f350c8 --- /dev/null +++ b/modules/frontend/src/pages/UserPage/UserPage.module.css @@ -0,0 +1,103 @@ +body, +html { + width: 100%; + margin: 0; + background-color: #777; + color: #fff; +} + +html, +body, +#root { + height: 100%; +} + +.header { + width: 100vw; + padding: 30px 40px; + background: #f7f7f7; + display: flex; + align-items: center; + gap: 25px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + border-bottom: 1px solid #e5e5e5; + color: #000000; +} + +.avatarWrapper { + width: 120px; + height: 120px; + min-width: 120px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #ddd; +} + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatarPlaceholder { + width: 100%; + height: 100%; + border-radius: 50%; + background: #ccc; + font-size: 42px; + font-weight: bold; + color: #555; + display: flex; + align-items: center; + justify-content: center; +} + +.userInfo { + display: flex; + flex-direction: column; +} + +.name { + font-size: 32px; + font-weight: 700; + margin: 0; +} + +.nickname { + font-size: 18px; + color: #666; + margin-top: 6px; +} + +.container { + max-width: 100vw; + width: 100%; + position: absolute; + top: 0%; + /* margin: 25px auto; */ + /* padding: 0 20px; */ +} + +.content { + margin-top: 20px; +} + +.desc { + font-size: 18px; + margin-bottom: 10px; +} + +.created { + font-size: 16px; + color: #888; +} + +.loader, +.error { + text-align: center; + margin-top: 40px; + font-size: 18px; +} diff --git a/modules/frontend/src/components/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx similarity index 80% rename from modules/frontend/src/components/UserPage/UserPage.tsx rename to modules/frontend/src/pages/UserPage/UserPage.tsx index 0a83679..eafdf6b 100644 --- a/modules/frontend/src/components/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -15,7 +15,7 @@ const UserPage: React.FC = () => { const getUserInfo = async () => { try { - const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id + const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id setUser(userInfo); } catch (err) { console.error(err); @@ -33,11 +33,11 @@ const UserPage: React.FC = () => { return ( <div className={styles.container}> - <div className={styles.card}> - <div className={styles.avatar}> - {user.avatar_id ? ( + <div className={styles.header}> + <div className={styles.avatarWrapper}> + {user.image?.image_path ? ( <img - src={`/images/${user.avatar_id}.png`} + src={`/images/${user.image.image_path}.png`} alt="User Avatar" className={styles.avatarImg} /> @@ -48,13 +48,16 @@ const UserPage: React.FC = () => { )} </div> - <div className={styles.info}> + <div className={styles.userInfo}> <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> <p className={styles.nickname}>@{user.nickname}</p> - {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} - <p className={styles.created}> + {/* <p className={styles.created}> Joined: {new Date(user.creation_date).toLocaleDateString()} - </p> + </p> */} + </div> + + <div className={styles.content}> + {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} </div> </div> </div> diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx new file mode 100644 index 0000000..729da20 --- /dev/null +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -0,0 +1,183 @@ +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; +import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; + +const PAGE_SIZE = 10; + +type UsersIdPageProps = { + userId?: string; +}; + +export default function UsersIdPage({ userId }: UsersIdPageProps) { + const params = useParams(); + const id = userId || params?.id; + + const [user, setUser] = useState<User | null>(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState<string | null>(null); + + // Для списка тайтлов + const [titles, setTitles] = useState<UserTitle[]>([]); + const [nextPage, setNextPage] = useState<UserTitle[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + // --- Получение данных пользователя --- + useEffect(() => { + const fetchUser = async () => { + if (!id) return; + setLoadingUser(true); + try { + const result = await DefaultService.getUsersId(id, "all"); + setUser(result); + setErrorUser(null); + } catch (err: any) { + console.error(err); + setErrorUser(err?.message || "Failed to fetch user data"); + } finally { + setLoadingUser(false); + } + }; + fetchUser(); + }, [id]); + + // --- Получение списка тайтлов пользователя --- + const fetchPage = async (cursorObj: CursorObj | null) => { + if (!id) return { items: [], nextCursor: null }; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : ""; + + try { + const result = await DefaultService.getUsersTitles( + id, + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, // status фильтр, можно добавить + undefined, // watchStatus + undefined, // rating + undefined, // myRate + undefined, // releaseYear + undefined, // releaseSeason + PAGE_SIZE, + "all" + ); + + if (!result?.data?.length) return { items: [], nextCursor: null }; + + return { items: result.data, nextCursor: result.cursor ?? null }; + } catch (err: any) { + if (err.status === 204) return { items: [], nextCursor: null }; + throw err; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoadingTitles(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoadingTitles(false); + }; + initLoad(); + }, [id, search, sort, sortForward]); + + const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) setNextPage(next.items); + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + setLoadingMore(false); + }; + + // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + {/* --- Карточка пользователя --- */} + {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} + {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} + {user && ( + <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> + <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> + {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} + {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} + {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} + </div> + )} + + {/* --- Панель поиска, сортировки и лейаута --- */} + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> + </div> + + {/* --- Список тайтлов --- */} + {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} + {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} + + {titles.length > 0 && ( + <> + <ListView<UserTitle> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length === 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} + </div> + ); +} diff --git a/modules/frontend/src/types/list.ts b/modules/frontend/src/types/list.ts new file mode 100644 index 0000000..582da39 --- /dev/null +++ b/modules/frontend/src/types/list.ts @@ -0,0 +1,12 @@ +export interface PaginatedResult<TItem> { + items: TItem[]; + nextCursor?: string; +} + +export interface FetchParams { + search: string; + cursor?: string; +} + +export type FetchFunction<TItem> = + (params: FetchParams) => Promise<PaginatedResult<TItem>>; diff --git a/modules/frontend/tsconfig.app.json b/modules/frontend/tsconfig.app.json index a9b5a59..2f416e5 100644 --- a/modules/frontend/tsconfig.app.json +++ b/modules/frontend/tsconfig.app.json @@ -20,7 +20,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, diff --git a/modules/frontend/tsconfig.node.json b/modules/frontend/tsconfig.node.json index 8a67f62..3439137 100644 --- a/modules/frontend/tsconfig.node.json +++ b/modules/frontend/tsconfig.node.json @@ -18,7 +18,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 4cfbdd0..554d630 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -1,11 +1,15 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + tailwindcss() + ], server: { - host: '127.0.0.1', + host: '0.0.0.0', port: 8083, }, }) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 0b7fa33..f8781de 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,6 +1,3 @@ --- TODO: --- maybe jsonb constraints --- clean unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); @@ -14,6 +11,7 @@ CREATE TABLE providers ( CREATE TABLE tags ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example: { "ru": "Сёдзё", "en": "Shojo", "jp": "少女"} tag_names jsonb NOT NULL ); @@ -25,28 +23,32 @@ CREATE TABLE images ( CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - avatar_id bigint REFERENCES images (id), + avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, passhash text NOT NULL, - mail text CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), - nickname text NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), + mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), + nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), disp_name text, user_desc text, - creation_date timestamptz NOT NULL, + creation_date timestamptz NOT NULL DEFAULT NOW(), last_login timestamptz ); + + CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - studio_name text UNIQUE, - illust_id bigint REFERENCES images (id), + studio_name text NOT NULL UNIQUE, + illust_id bigint REFERENCES images (id) ON DELETE SET NULL, studio_desc text ); CREATE TABLE titles ( + -- // TODO: anime type (film, season etc) id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, studio_id bigint NOT NULL REFERENCES studios (id), - poster_id bigint REFERENCES images (id), + poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, rating float CHECK (rating >= 0 AND rating <= 10), rating_count int CHECK (rating_count >= 0), @@ -55,26 +57,43 @@ CREATE TABLE titles ( season int CHECK (season >= 0), episodes_aired int CHECK (episodes_aired >= 0), episodes_all int CHECK (episodes_all >= 0), + -- example { "1": "50.50", "2": "23.23"} episodes_len jsonb, CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL AND episodes_aired <= episodes_all)) ); +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + user_id bigint REFERENCES users (id) ON DELETE SET NULL, + title_id bigint REFERENCES titles (id) ON DELETE CASCADE, + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + CREATE TABLE usertitles ( PRIMARY KEY (user_id, title_id), - user_id bigint NOT NULL REFERENCES users (id), - title_id bigint NOT NULL REFERENCES titles (id), + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_text text, - review_date timestamptz + review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, + ctime timestamptz NOT NULL DEFAULT now() + -- // TODO: series status ); CREATE TABLE title_tags ( PRIMARY KEY (title_id, tag_id), - title_id bigint NOT NULL REFERENCES titles (id), - tag_id bigint NOT NULL REFERENCES tags (id) + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE ); CREATE TABLE signals ( @@ -85,6 +104,17 @@ CREATE TABLE signals ( pending boolean NOT NULL ); +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL +); + +CREATE TABLE external_ids ( + user_id bigint NOT NULL REFERENCES users (id), + service_id bigint REFERENCES external_services (id), + external_id text NOT NULL +); + -- Functions CREATE OR REPLACE FUNCTION update_title_rating() RETURNS TRIGGER AS $$ @@ -138,4 +168,17 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); + +CREATE OR REPLACE FUNCTION set_ctime() +RETURNS TRIGGER AS $$ +BEGIN + NEW.ctime = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_ctime_on_update +AFTER UPDATE ON usertitles +FOR EACH ROW +EXECUTE FUNCTION set_ctime(); \ No newline at end of file diff --git a/sql/models.go b/sql/models.go index 928d5ac..842d58c 100644 --- a/sql/models.go +++ b/sql/models.go @@ -6,6 +6,7 @@ package sqlc import ( "database/sql/driver" + "encoding/json" "fmt" "time" @@ -185,6 +186,17 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { return string(ns.UsertitleStatusT), nil } +type ExternalID struct { + UserID int64 `json:"user_id"` + ServiceID *int64 `json:"service_id"` + ExternalID string `json:"external_id"` +} + +type ExternalService struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + type Image struct { ID int64 `json:"id"` StorageType StorageTypeT `json:"storage_type"` @@ -197,40 +209,54 @@ type Provider struct { Credentials []byte `json:"credentials"` } +type Review struct { + ID int64 `json:"id"` + Data string `json:"data"` + Rating *int32 `json:"rating"` + UserID *int64 `json:"user_id"` + TitleID *int64 `json:"title_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ReviewImage struct { + ReviewID int64 `json:"review_id"` + ImageID int64 `json:"image_id"` +} + type Signal struct { - ID int64 `json:"id"` - TitleID *int64 `json:"title_id"` - RawData []byte `json:"raw_data"` - ProviderID int64 `json:"provider_id"` - Pending bool `json:"pending"` + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` + RawData json.RawMessage `json:"raw_data"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` } type Studio struct { ID int64 `json:"id"` - StudioName *string `json:"studio_name"` + StudioName string `json:"studio_name"` IllustID *int64 `json:"illust_id"` StudioDesc *string `json:"studio_desc"` } type Tag struct { - ID int64 `json:"id"` - TagNames []byte `json:"tag_names"` + ID int64 `json:"id"` + TagNames json.RawMessage `json:"tag_names"` } type Title struct { - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason NullReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` } type TitleTag struct { @@ -251,10 +277,10 @@ type User struct { } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewText *string `json:"review_text"` - ReviewDate pgtype.Timestamptz `json:"review_date"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime time.Time `json:"ctime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 8f92c2a..a46da86 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,6 +7,7 @@ package sqlc import ( "context" + "encoding/json" "time" ) @@ -31,30 +32,223 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = $1 +WHERE id = $1::bigint ` -func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { - row := q.db.QueryRow(ctx, getImageByID, id) +func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, illustID) var i Image err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) return i, err } +const getReviewByID = `-- name: GetReviewByID :one + + + +SELECT id, data, rating, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint +` + +// 100 is default limit +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Review + err := row.Scan( + &i.ID, + &i.Data, + &i.Rating, + &i.UserID, + &i.TitleID, + &i.CreatedAt, + ) + return i, err +} + +const getStudioByID = `-- name: GetStudioByID :one +SELECT id, studio_name, illust_id, studio_desc +FROM studios +WHERE id = $1::bigint +` + +func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, error) { + row := q.db.QueryRow(ctx, getStudioByID, studioID) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + +const getTitleByID = `-- name: GetTitleByID :one + +SELECT + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = $1::bigint +GROUP BY + t.id, i.id, s.id, si.id +` + +type GetTitleByIDRow struct { + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + +// -- name: DeleteUser :exec +// DELETE FROM users +// WHERE user_id = $1; +// sqlc.struct: TitlesFull +func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByIDRow, error) { + row := q.db.QueryRow(ctx, getTitleByID, titleID) + var i GetTitleByIDRow + err := row.Scan( + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, + ) + return i, err +} + +const getTitleTags = `-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = $1::bigint +` + +func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMessage, error) { + rows, err := q.db.Query(ctx, getTitleTags, titleID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var tag_names json.RawMessage + if err := rows.Scan(&tag_names); err != nil { + return nil, err + } + items = append(items, tag_names) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUserByID = `-- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1 +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE t.id = $1::bigint ` type GetUserByIDRow struct { - ID int64 `json:"id"` - AvatarID *int64 `json:"avatar_id"` - Mail *string `json:"mail"` - Nickname string `json:"nickname"` - DispName *string `json:"disp_name"` - UserDesc *string `json:"user_desc"` - CreationDate time.Time `json:"creation_date"` + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` } func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) { @@ -68,6 +262,666 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er &i.DispName, &i.UserDesc, &i.CreationDate, + &i.StorageType, + &i.ImagePath, + ) + return i, err +} + +const insertStudio = `-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + $1::text, + $2::bigint, + $3::text) +RETURNING id, studio_name, illust_id, studio_desc +` + +type InsertStudioParams struct { + StudioName string `json:"studio_name"` + IllustID *int64 `json:"illust_id"` + StudioDesc *string `json:"studio_desc"` +} + +func (q *Queries) InsertStudio(ctx context.Context, arg InsertStudioParams) (Studio, error) { + row := q.db.QueryRow(ctx, insertStudio, arg.StudioName, arg.IllustID, arg.StudioDesc) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + +const insertTag = `-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + $1::jsonb) +RETURNING id, tag_names +` + +func (q *Queries) InsertTag(ctx context.Context, tagNames json.RawMessage) (Tag, error) { + row := q.db.QueryRow(ctx, insertTag, tagNames) + var i Tag + err := row.Scan(&i.ID, &i.TagNames) + return i, err +} + +const insertTitleTags = `-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + $1::bigint, + $2::bigint) +RETURNING title_id, tag_id +` + +type InsertTitleTagsParams struct { + TitleID int64 `json:"title_id"` + TagID int64 `json:"tag_id"` +} + +func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams) (TitleTag, error) { + row := q.db.QueryRow(ctx, insertTitleTags, arg.TitleID, arg.TagID) + var i TitleTag + err := row.Scan(&i.TitleID, &i.TagID) + return i, err +} + +const insertUserTitle = `-- name: InsertUserTitle :one + + + + + + + +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + $1::bigint, + $2::bigint, + $3::usertitle_status_t, + $4::int, + $5::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type InsertUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` +} + +// -- name: CreateReview :one +// INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +// VALUES ($1, $2, $3, $4, $5) +// RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; +// -- name: UpdateReview :one +// UPDATE reviews +// SET +// +// image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), +// review_text = COALESCE(sqlc.narg('review_text'), review_text) +// +// WHERE review_id = sqlc.arg('review_id') +// RETURNING *; +// -- name: DeleteReview :exec +// DELETE FROM reviews +// WHERE review_id = $1; +// +// -- name: ListReviewsByTitle :many +// +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE title_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: ListReviewsByUser :many +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE user_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: GetUserTitle :one +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 AND title_id = $2; +// -- name: ListUserTitles :many +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 +// ORDER BY usertitle_id +// LIMIT $2 OFFSET $3; +func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, insertUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + +const searchTitles = `-- name: SearchTitles :many +SELECT + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name + +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + CASE + WHEN $1::boolean THEN + -- forward: greater than cursor (next page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year > $3::int) OR + (t.release_year = $3::int AND t.id > $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating > $5::float) OR + (t.rating = $5::float AND t.id > $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id > $4::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year < $3::int) OR + (t.release_year = $3::int AND t.id < $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating < $5::float) OR + (t.rating = $5::float AND t.id < $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id < $4::bigint) + + ELSE true + END + END + + AND ( + CASE + WHEN $6::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($6::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + + AND ( + $7::title_status_t[] IS NULL + OR array_length($7::title_status_t[], 1) IS NULL + OR array_length($7::title_status_t[], 1) = 0 + OR t.title_status = ANY($7::title_status_t[]) + ) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + +GROUP BY + t.id, i.id, s.id + +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 + 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 + END + END DESC, + + CASE WHEN $2::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE($11::int, 100) +` + +type SearchTitlesParams struct { + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` +} + +type SearchTitlesRow struct { + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` +} + +func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { + rows, err := q.db.Query(ctx, searchTitles, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, + arg.Word, + arg.TitleStatuses, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchTitlesRow{} + for rows.Next() { + var i SearchTitlesRow + if err := rows.Scan( + &i.ID, + &i.TitleNames, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchUserTitles = `-- name: SearchUserTitles :many + +SELECT + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name + +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + u.user_id = $1::bigint + AND + CASE + WHEN $2::boolean THEN + -- forward: greater than cursor (next page) + CASE $3::text + WHEN 'year' THEN + ($4::int IS NULL) OR + (t.release_year > $4::int) OR + (t.release_year = $4::int AND t.id > $5::bigint) + + WHEN 'rating' THEN + ($6::float IS NULL) OR + (t.rating > $6::float) OR + (t.rating = $6::float AND t.id > $5::bigint) + + WHEN 'id' THEN + ($5::bigint IS NULL) OR + (t.id > $5::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $3::text + WHEN 'year' THEN + ($4::int IS NULL) OR + (t.release_year < $4::int) OR + (t.release_year = $4::int AND t.id < $5::bigint) + + WHEN 'rating' THEN + ($6::float IS NULL) OR + (t.rating < $6::float) OR + (t.rating = $6::float AND t.id < $5::bigint) + + WHEN 'id' THEN + ($5::bigint IS NULL) OR + (t.id < $5::bigint) + + ELSE true + END + END + + AND ( + CASE + WHEN $7::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($7::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + + AND ( + $8::title_status_t[] IS NULL + OR array_length($8::title_status_t[], 1) IS NULL + OR array_length($8::title_status_t[], 1) = 0 + OR t.title_status = ANY($8::title_status_t[]) + ) + AND ( + $9::usertitle_status_t[] IS NULL + OR array_length($9::usertitle_status_t[], 1) IS NULL + OR array_length($9::usertitle_status_t[], 1) = 0 + OR u.status = ANY($9::usertitle_status_t[]) + ) + AND ($10::int IS NULL OR u.rate >= $10::int) + AND ($11::float IS NULL OR t.rating >= $11::float) + AND ($12::int IS NULL OR t.release_year = $12::int) + AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t) + +GROUP BY + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + +ORDER BY + CASE WHEN $2::boolean THEN + CASE + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate + END + END ASC, + CASE WHEN NOT $2::boolean THEN + CASE + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate + END + END DESC, + + CASE WHEN $3::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE($14::int, 100) +` + +type SearchUserTitlesParams struct { + UserID int64 `json:"user_id"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` +} + +type SearchUserTitlesRow struct { + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime time.Time `json:"user_ctime"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` +} + +// 100 is default limit +func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { + rows, err := q.db.Query(ctx, searchUserTitles, + arg.UserID, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, + arg.Word, + arg.TitleStatuses, + arg.UsertitleStatuses, + arg.Rate, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchUserTitlesRow{} + for rows.Next() { + var i SearchUserTitlesRow + if err := rows.Scan( + &i.ID, + &i.TitleNames, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.UserID, + &i.UsertitleStatus, + &i.UserRate, + &i.ReviewID, + &i.UserCtime, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUser = `-- name: UpdateUser :one + + +UPDATE users +SET + avatar_id = COALESCE($1, avatar_id), + disp_name = COALESCE($2, disp_name), + user_desc = COALESCE($3, user_desc), + mail = COALESCE($4, mail) +WHERE id = $5 +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail +` + +type UpdateUserParams struct { + AvatarID *int64 `json:"avatar_id"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + Mail *string `json:"mail"` + UserID int64 `json:"user_id"` +} + +type UpdateUserRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + Mail *string `json:"mail"` +} + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, + arg.AvatarID, + arg.DispName, + arg.UserDesc, + arg.Mail, + arg.UserID, + ) + var i UpdateUserRow + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.Mail, ) return i, err } diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f44761e..de67bcf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -12,7 +12,15 @@ sql: sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: true emit_pointers_for_null_types: true + emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - db_type: "storage_type_t" + nullable: true + go_type: + type: "StorageTypeT" + pointer: true + - db_type: "jsonb" + go_type: "encoding/json.RawMessage" - db_type: "uuid" nullable: false go_type: @@ -24,4 +32,14 @@ sql: nullable: false go_type: import: "time" - type: "Time" \ No newline at end of file + type: "Time" + - db_type: "title_status_t" + nullable: true + go_type: + pointer: true + type: "TitleStatusT" + - db_type: "release_season_t" + nullable: true + go_type: + pointer: true + type: "ReleaseSeasonT" \ No newline at end of file