Compare commits

..

No commits in common. "fd8ecbeacaca78ed52c9da7b6907d10665dbfc75" and "9c0fada00e0ef74e9b482948b51463ff9d1087c6" have entirely different histories.

36 changed files with 1242 additions and 792 deletions

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,4 +5,9 @@
/**
* User's title status
*/
export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress';
export enum UserTitleStatus {
FINISHED = 'finished',
PLANNED = 'planned',
DROPPED = 'dropped',
IN_PROGRESS = 'in-progress',
}

View file

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

View file

@ -0,0 +1,25 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
export class ApiError extends Error {
public readonly url: string;
public readonly status: number;
public readonly statusText: string;
public readonly body: any;
public readonly request: ApiRequestOptions;
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
super(message);
this.name = 'ApiError';
this.url = response.url;
this.status = response.status;
this.statusText = response.statusText;
this.body = response.body;
this.request = request;
}
}

View file

@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiRequestOptions = {
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
readonly url: string;
readonly path?: Record<string, any>;
readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>;
readonly query?: Record<string, any>;
readonly formData?: Record<string, any>;
readonly body?: any;
readonly mediaType?: string;
readonly responseHeader?: string;
readonly errors?: Record<number, string>;
};

View file

@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiResult = {
readonly url: string;
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly body: any;
};

View file

@ -0,0 +1,131 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancelError';
}
public get isCancelled(): boolean {
return true;
}
}
export interface OnCancel {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;
(cancelHandler: () => void): void;
}
export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void,
onCancel: OnCancel
) => void
) {
this.#isResolved = false;
this.#isRejected = false;
this.#isCancelled = false;
this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isResolved = true;
if (this.#resolve) this.#resolve(value);
};
const onReject = (reason?: any): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return "Cancellable Promise";
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
}
public get isCancelled(): boolean {
return this.#isCancelled;
}
}

View file

@ -0,0 +1,32 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers = Record<string, string>;
export type OpenAPIConfig = {
BASE: string;
VERSION: string;
WITH_CREDENTIALS: boolean;
CREDENTIALS: 'include' | 'omit' | 'same-origin';
TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined;
PASSWORD?: string | Resolver<string> | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined;
ENCODE_PATH?: ((path: string) => string) | undefined;
};
export const OpenAPI: OpenAPIConfig = {
BASE: '/api/v1',
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};

View file

@ -0,0 +1,323 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
import FormData from 'form-data';
import { ApiError } from './ApiError';
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
import { CancelablePromise } from './CancelablePromise';
import type { OnCancel } from './CancelablePromise';
import type { OpenAPIConfig } from './OpenAPI';
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null;
};
export const isString = (value: any): value is string => {
return typeof value === 'string';
};
export const isStringWithValue = (value: any): value is string => {
return isString(value) && value !== '';
};
export const isBlob = (value: any): value is Blob => {
return (
typeof value === 'object' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
typeof value.arrayBuffer === 'function' &&
typeof value.constructor === 'function' &&
typeof value.constructor.name === 'string' &&
/^(Blob|File)$/.test(value.constructor.name) &&
/^(Blob|File)$/.test(value[Symbol.toStringTag])
);
};
export const isFormData = (value: any): value is FormData => {
return value instanceof FormData;
};
export const isSuccess = (status: number): boolean => {
return status >= 200 && status < 300;
};
export const base64 = (str: string): string => {
try {
return btoa(str);
} catch (err) {
// @ts-ignore
return Buffer.from(str).toString('base64');
}
};
export const getQueryString = (params: Record<string, any>): string => {
const qs: string[] = [];
const append = (key: string, value: any) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
};
const process = (key: string, value: any) => {
if (isDefined(value)) {
if (Array.isArray(value)) {
value.forEach(v => {
process(key, v);
});
} else if (typeof value === 'object') {
Object.entries(value).forEach(([k, v]) => {
process(`${key}[${k}]`, v);
});
} else {
append(key, value);
}
}
};
Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
}
return '';
};
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url
.replace('{api-version}', config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group]));
}
return substring;
});
const url = `${config.BASE}${path}`;
if (options.query) {
return `${url}${getQueryString(options.query)}`;
}
return url;
};
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
if (options.formData) {
const formData = new FormData();
const process = (key: string, value: any) => {
if (isString(value) || isBlob(value)) {
formData.append(key, value);
} else {
formData.append(key, JSON.stringify(value));
}
};
Object.entries(options.formData)
.filter(([_, value]) => isDefined(value))
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => process(key, v));
} else {
process(key, value);
}
});
return formData;
}
return undefined;
};
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => {
if (typeof resolver === 'function') {
return (resolver as Resolver<T>)(options);
}
return resolver;
};
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => {
const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.TOKEN),
resolve(options, config.USERNAME),
resolve(options, config.PASSWORD),
resolve(options, config.HEADERS),
]);
const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {}
const headers = Object.entries({
Accept: 'application/json',
...additionalHeaders,
...options.headers,
...formHeaders,
})
.filter(([_, value]) => isDefined(value))
.reduce((headers, [key, value]) => ({
...headers,
[key]: String(value),
}), {} as Record<string, string>);
if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
}
}
return headers;
};
export const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body) {
return options.body;
}
return undefined;
};
export const sendRequest = async <T>(
config: OpenAPIConfig,
options: ApiRequestOptions,
url: string,
body: any,
formData: FormData | undefined,
headers: Record<string, string>,
onCancel: OnCancel,
axiosClient: AxiosInstance
): Promise<AxiosResponse<T>> => {
const source = axios.CancelToken.source();
const requestConfig: AxiosRequestConfig = {
url,
headers,
data: body ?? formData,
method: options.method,
withCredentials: config.WITH_CREDENTIALS,
withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false,
cancelToken: source.token,
};
onCancel(() => source.cancel('The user aborted a request.'));
try {
return await axiosClient.request(requestConfig);
} catch (error) {
const axiosError = error as AxiosError<T>;
if (axiosError.response) {
return axiosError.response;
}
throw error;
}
};
export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => {
if (responseHeader) {
const content = response.headers[responseHeader];
if (isString(content)) {
return content;
}
}
return undefined;
};
export const getResponseBody = (response: AxiosResponse<any>): any => {
if (response.status !== 204) {
return response.data;
}
return undefined;
};
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
const errors: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
...options.errors,
}
const error = errors[result.status];
if (error) {
throw new ApiError(options, result, error);
}
if (!result.ok) {
const errorStatus = result.status ?? 'unknown';
const errorStatusText = result.statusText ?? 'unknown';
const errorBody = (() => {
try {
return JSON.stringify(result.body, null, 2);
} catch (e) {
return undefined;
}
})();
throw new ApiError(options, result,
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
);
}
};
/**
* Request method
* @param config The OpenAPI configuration object
* @param options The request options from the service
* @param axiosClient The axios client instance to use
* @returns CancelablePromise<T>
* @throws ApiError
*/
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => {
return new CancelablePromise(async (resolve, reject, onCancel) => {
try {
const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options, formData);
if (!onCancel.isCancelled) {
const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient);
const responseBody = getResponseBody(response);
const responseHeader = getResponseHeader(response, options.responseHeader);
const result: ApiResult = {
url,
ok: isSuccess(response.status),
status: response.status,
statusText: response.statusText,
body: responseHeader ?? responseBody,
};
catchErrorCodes(options, result);
resolve(result.body);
}
} catch (error) {
reject(error);
}
});
};

View file

@ -0,0 +1,23 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export { ApiError } from './core/ApiError';
export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { cursor } from './models/cursor';
export type { Image } from './models/Image';
export { ReleaseSeason } from './models/ReleaseSeason';
export type { Review } from './models/Review';
export type { Studio } from './models/Studio';
export type { Tag } from './models/Tag';
export type { Tags } from './models/Tags';
export type { Title } from './models/Title';
export { TitleStatus } from './models/TitleStatus';
export type { User } from './models/User';
export type { UserTitle } from './models/UserTitle';
export { UserTitleStatus } from './models/UserTitleStatus';
export { DefaultService } from './services/DefaultService';

View file

@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Image = {
id?: number;
storage_type?: string;
image_path?: string;
};

View file

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Title release season
*/
export enum ReleaseSeason {
WINTER = 'winter',
SPRING = 'spring',
SUMMER = 'summer',
FALL = 'fall',
}

View file

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

View file

@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Image } from './Image';
export type Studio = {
id: number;
name: string;
poster?: Image;
description?: string;
};

View file

@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* A localized tag: keys are language codes (ISO 639-1), values are tag names
*/
export type Tag = Record<string, string>;

View file

@ -2,7 +2,8 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Tag } from './Tag';
/**
* Title sort order
* Array of localized tags
*/
export type TitleSort = 'id' | 'year' | 'rating' | 'views';
export type Tags = Array<Tag>;

View file

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

View file

@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Title status
*/
export enum TitleStatus {
FINISHED = 'finished',
ONGOING = 'ongoing',
PLANNED = 'planned',
}

View file

@ -0,0 +1,35 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type User = {
/**
* Unique user ID (primary key)
*/
id: number;
/**
* ID of the user avatar (references images table)
*/
avatar_id?: number | null;
/**
* User email
*/
mail?: string;
/**
* Username (alphanumeric + _ or -)
*/
nickname: string;
/**
* Display name
*/
disp_name?: string;
/**
* User description
*/
user_desc?: string;
/**
* Timestamp when the user was created
*/
creation_date?: string;
};

View file

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

View file

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* User's title status
*/
export enum UserTitleStatus {
FINISHED = 'finished',
PLANNED = 'planned',
DROPPED = 'dropped',
IN_PROGRESS = 'in-progress',
}

View file

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

View file

@ -0,0 +1,148 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ReleaseSeason } from '../models/ReleaseSeason';
import type { Title } from '../models/Title';
import type { TitleStatus } from '../models/TitleStatus';
import type { User } from '../models/User';
import type { UserTitle } from '../models/UserTitle';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class DefaultService {
/**
* Get titles
* @param word
* @param status
* @param rating
* @param releaseYear
* @param releaseSeason
* @param limit
* @param offset
* @param fields
* @returns Title List of titles
* @throws ApiError
*/
public static getTitles(
word?: string,
status?: TitleStatus,
rating?: number,
releaseYear?: number,
releaseSeason?: ReleaseSeason,
limit: number = 10,
offset?: number,
fields: string = 'all',
): CancelablePromise<Array<Title>> {
return __request(OpenAPI, {
method: 'GET',
url: '/titles',
query: {
'word': word,
'status': status,
'rating': rating,
'release_year': releaseYear,
'release_season': releaseSeason,
'limit': limit,
'offset': offset,
'fields': fields,
},
errors: {
400: `Request params are not correct`,
500: `Unknown server error`,
},
});
}
/**
* Get title description
* @param titleId
* @param fields
* @returns Title Title description
* @throws ApiError
*/
public static getTitles1(
titleId: number,
fields: string = 'all',
): CancelablePromise<Title> {
return __request(OpenAPI, {
method: 'GET',
url: '/titles/{title_id}',
path: {
'title_id': titleId,
},
query: {
'fields': fields,
},
errors: {
400: `Request params are not correct`,
404: `Title not found`,
500: `Unknown server error`,
},
});
}
/**
* Get user info
* @param userId
* @param fields
* @returns User User info
* @throws ApiError
*/
public static getUsers(
userId: string,
fields: string = 'all',
): CancelablePromise<User> {
return __request(OpenAPI, {
method: 'GET',
url: '/users/{user_id}',
path: {
'user_id': userId,
},
query: {
'fields': fields,
},
errors: {
400: `Request params are not correct`,
404: `User not found`,
500: `Unknown server error`,
},
});
}
/**
* Get user titles
* @param userId
* @param cursor
* @param query
* @param limit
* @param offset
* @param fields
* @returns UserTitle List of user titles
* @throws ApiError
*/
public static getUsersTitles(
userId: string,
cursor?: string,
query?: string,
limit: number = 10,
offset?: number,
fields: string = 'all',
): CancelablePromise<Array<UserTitle>> {
return __request(OpenAPI, {
method: 'GET',
url: '/users/{user_id}/titles',
path: {
'user_id': userId,
},
query: {
'cursor': cursor,
'query': query,
'limit': limit,
'offset': offset,
'fields': fields,
},
errors: {
400: `Request params are not correct`,
500: `Unknown server error`,
},
});
}
}

View file

@ -1,103 +1,52 @@
import React, { useState, useEffect } from "react";
import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid";
import type { CursorObj } from "../../api";
import React from "react";
export type ListViewProps<T> = {
fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>;
renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode;
pageSize?: number;
searchPlaceholder?: string;
setSearch: any;
};
export function ListView<T>({
fetchItems,
renderItem,
pageSize = 20,
searchPlaceholder = "Search...",
}: ListViewProps<T>) {
const [items, setItems] = useState<T[]>([]);
const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState("");
const [layout, setLayout] = useState<"square" | "horizontal">("horizontal");
const [error, setError] = useState<string | null>(null);
const loadItems = async (reset: boolean = false) => {
try {
if (reset) {
setLoading(true);
setCursorObj(undefined);
} else {
setLoadingMore(true);
interface ListViewProps<TItem> {
hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>;
renderHorizontal: (item: TItem) => React.ReactNode;
renderSquare: (item: TItem) => React.ReactNode;
}
const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : ""
console.log("encoded cursor: " + cursorStr)
const result = await fetchItems(cursorStr, pageSize);
if (reset) setItems(result.items);
else setItems(prev => [...prev, ...result.items]);
setCursorObj(result.cursor);
setError(null);
} catch (err: any) {
console.error(err);
setError("Failed to fetch items.");
} finally {
setLoading(false);
setLoadingMore(false);
}
};
useEffect(() => {
loadItems(true);
}, [search]);
export function ListView<TItem>({
hook,
renderHorizontal,
renderSquare
}: ListViewProps<TItem>) {
const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook;
return (
<div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center">
<div className="w-full sm:w-4/5 flex gap-4 mb-8">
<div>
{/* Search + Layout Switcher */}
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<input
type="text"
placeholder={searchPlaceholder}
// value={search}
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black"
/>
<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>
<button onClick={() => setViewMode("horizontal")}>Horizontal</button>
<button onClick={() => setViewMode("square")}>Square</button>
</div>
{error && <div className="text-red-600 mb-6 font-medium">{error}</div>}
{/* 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"
}`}
style={{
display: "grid",
gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr",
gap: 12
}}
>
{items.map(item => renderItem(item, layout))}
</div>
{cursorObj && (
<div className="mt-8 flex justify-center w-full sm:w-4/5">
<button
className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => loadItems(false)}
disabled={loadingMore}
>
{loadingMore ? "Loading..." : "Load More"}
</button>
</div>
{items.map(item =>
viewMode === "horizontal"
? renderHorizontal(item)
: renderSquare(item)
)}
</div>
{loading && <div className="mt-20 font-medium">Loading...</div>}
{hasMore && (
<button onClick={loadMore} style={{ marginTop: 16 }}>
Load More
</button>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
import { useState, useEffect } from "react";
import type { FetchFunction } from "../../types/list";
export function useListView<TItem>(fetchFn: FetchFunction<TItem>) {
const [items, setItems] = useState<TItem[]>([]);
const [cursor, setCursor] = useState<string | undefined>();
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"horizontal" | "square">("horizontal");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadItems(true);
}, [search]);
const loadItems = async (reset = false) => {
setIsLoading(true);
const result = await fetchFn({
search,
cursor: reset ? undefined : cursor,
});
setItems(prev => reset ? result.items : [...prev, ...result.items]);
setCursor(result.nextCursor);
setIsLoading(false);
};
return {
items,
search,
setSearch,
viewMode,
setViewMode,
loadMore: () => loadItems(),
hasMore: Boolean(cursor),
isLoading,
};
}

View file

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

View file

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

View file

@ -1,8 +1,68 @@
@import "tailwindcss";
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
html, body, #root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
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;
}
}

View file

@ -1 +1,59 @@
@import "tailwindcss";
.container {
padding: 24px;
}
.header {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
.searchInput {
padding: 8px;
width: 240px;
}
.list {
display: grid;
gap: 12px;
}
.card {
display: flex;
padding: 10px;
border: 1px solid #ddd;
border-radius: 8px;
gap: 12px;
}
.poster {
width: 80px;
height: 120px;
object-fit: cover;
border-radius: 4px;
}
.posterPlaceholder {
width: 80px;
height: 120px;
background: #eee;
display: flex;
align-items: center;
justify-content: center;
}
.cardInfo {
display: flex;
flex-direction: column;
}
.loadMore {
margin-top: 16px;
padding: 8px 16px;
}
.loader,
.error {
padding: 20px;
text-align: center;
}

View file

@ -1,52 +1,114 @@
import { ListView } from "../../components/ListView/ListView";
import React, { useEffect, useState } from "react";
import { DefaultService } from "../../api/services/DefaultService";
import { TitleCardSquare } from "../../components/cards/TitleCardSquare";
import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal";
import type { Title } from "../../api";
import { useState, useEffect } from "react";
import type { Title } from "../../api/models/Title";
import styles from "./TitlesPage.module.css";
const PAGE_SIZE = 20;
export default function TitlesPage() {
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 loadTitles = async (cursor: string, limit: number) => {
const result = await DefaultService.getTitles(
cursor,
undefined,
true,
search,
undefined,
undefined,
undefined,
undefined,
limit,
undefined,
'all'
search || undefined,
undefined, // status
undefined, // rating
undefined, // release_year
undefined, // release_season
LIMIT,
reset ? 0 : offset,
"all"
);
return {
items: result.data ?? [],
cursor: result.cursor ?? null,
};
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="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center">
<div className={styles.container}>
<div className={styles.header}>
<h1>Titles</h1>
<h1 className="text-4xl font-bold mb-6 text-center">Titles</h1>
<ListView<Title>
pageSize={PAGE_SIZE}
fetchItems={loadTitles}
searchPlaceholder="Search titles..."
renderItem={(title, layout) =>
layout === "square"
? <TitleCardSquare title={title} />
: <TitleCardHorizontal title={title} />
}
setSearch={setSearch}
<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;

View file

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