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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4"
"tailwindcss": "^4.1.17"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,103 +1,52 @@
import React, { useState, useEffect } from "react"; import React from "react";
import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid";
import type { CursorObj } from "../../api";
export type ListViewProps<T> = { interface ListViewProps<TItem> {
fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>;
renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; renderHorizontal: (item: TItem) => React.ReactNode;
pageSize?: number; renderSquare: (item: TItem) => React.ReactNode;
searchPlaceholder?: string; }
setSearch: any;
};
export function ListView<T>({ export function ListView<TItem>({
fetchItems, hook,
renderItem, renderHorizontal,
pageSize = 20, renderSquare
searchPlaceholder = "Search...", }: ListViewProps<TItem>) {
}: ListViewProps<T>) { const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook;
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);
}
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]);
return ( return (
<div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> <div>
<div className="w-full sm:w-4/5 flex gap-4 mb-8"> {/* Search + Layout Switcher */}
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<input <input
type="text" placeholder="Search..."
placeholder={searchPlaceholder} value={search}
// value={search}
onChange={e => setSearch(e.target.value)} 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" <button onClick={() => setViewMode("horizontal")}>Horizontal</button>
onClick={() => <button onClick={() => setViewMode("square")}>Square</button>
setLayout(prev => (prev === "square" ? "horizontal" : "square"))
}>
{layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
</button>
</div> </div>
{error && <div className="text-red-600 mb-6 font-medium">{error}</div>} {/* Items */}
<div <div
className={`w-full sm:w-4/5 grid gap-6 ${ style={{
layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" display: "grid",
}`} gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr",
gap: 12
}}
> >
{items.map(item => renderItem(item, layout))} {items.map(item =>
viewMode === "horizontal"
? renderHorizontal(item)
: renderSquare(item)
)}
</div> </div>
{cursorObj && ( {hasMore && (
<div className="mt-8 flex justify-center w-full sm:w-4/5"> <button onClick={loadMore} style={{ marginTop: 16 }}>
<button Load More
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" </button>
onClick={() => loadItems(false)}
disabled={loadingMore}
>
{loadingMore ? "Loading..." : "Load More"}
</button>
</div>
)} )}
{loading && <div className="mt-20 font-medium">Loading...</div>}
</div> </div>
); );
} }

View file

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

View file

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

View file

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

View file

@ -1,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 { color-scheme: light dark;
margin: 0; color: rgba(255, 255, 255, 0.87);
padding: 0; background-color: #242424;
width: 100%;
height: 100%; 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 { DefaultService } from "../../api/services/DefaultService";
import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import type { Title } from "../../api/models/Title";
import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import styles from "./TitlesPage.module.css";
import type { Title } from "../../api";
import { useState, useEffect } from "react";
const PAGE_SIZE = 20; const LIMIT = 20;
export default function TitlesPage() {
const TitlesPage: React.FC = () => {
const [titles, setTitles] = useState<Title[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [offset, setOffset] = useState(0);
const loadTitles = async (cursor: string, limit: number) => { const [loading, setLoading] = useState(true);
const result = await DefaultService.getTitles( const [loadingMore, setLoadingMore] = useState(false);
cursor, const [error, setError] = useState<string | null>(null);
undefined,
true,
search,
undefined,
undefined,
undefined,
undefined,
limit,
undefined,
'all'
);
return { const fetchTitles = async (reset: boolean) => {
items: result.data ?? [], try {
cursor: result.cursor ?? null, 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 ( 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> <input
className={styles.searchInput}
placeholder="Search titles..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<ListView<Title> <div className={styles.list}>
pageSize={PAGE_SIZE} {titles.map((t) => (
fetchItems={loadTitles} <div key={t.id} className={styles.card}>
searchPlaceholder="Search titles..." {t.poster_id ? (
renderItem={(title, layout) => <img
layout === "square" src={`/images/${t.poster_id}.png`}
? <TitleCardSquare title={title} /> alt="Poster"
: <TitleCardHorizontal title={title} /> className={styles.poster}
} />
setSearch={setSearch} ) : (
/> <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> </div>
); );
} };
export default TitlesPage;

View file

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