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