Compare commits
No commits in common. "fd8ecbeacaca78ed52c9da7b6907d10665dbfc75" and "9c0fada00e0ef74e9b482948b51463ff9d1087c6" have entirely different histories.
fd8ecbeaca
...
9c0fada00e
36 changed files with 1242 additions and 792 deletions
651
modules/frontend/package-lock.json
generated
651
modules/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,8 @@
|
||||||
/**
|
/**
|
||||||
* Title status
|
* Title status
|
||||||
*/
|
*/
|
||||||
export type TitleStatus = 'finished' | 'ongoing' | 'planned';
|
export enum TitleStatus {
|
||||||
|
FINISHED = 'finished',
|
||||||
|
ONGOING = 'ongoing',
|
||||||
|
PLANNED = 'planned',
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
25
modules/frontend/src/api_/core/ApiError.ts
Normal file
25
modules/frontend/src/api_/core/ApiError.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
modules/frontend/src/api_/core/ApiRequestOptions.ts
Normal file
17
modules/frontend/src/api_/core/ApiRequestOptions.ts
Normal 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>;
|
||||||
|
};
|
||||||
11
modules/frontend/src/api_/core/ApiResult.ts
Normal file
11
modules/frontend/src/api_/core/ApiResult.ts
Normal 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;
|
||||||
|
};
|
||||||
131
modules/frontend/src/api_/core/CancelablePromise.ts
Normal file
131
modules/frontend/src/api_/core/CancelablePromise.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
modules/frontend/src/api_/core/OpenAPI.ts
Normal file
32
modules/frontend/src/api_/core/OpenAPI.ts
Normal 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,
|
||||||
|
};
|
||||||
323
modules/frontend/src/api_/core/request.ts
Normal file
323
modules/frontend/src/api_/core/request.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
23
modules/frontend/src/api_/index.ts
Normal file
23
modules/frontend/src/api_/index.ts
Normal 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';
|
||||||
10
modules/frontend/src/api_/models/Image.ts
Normal file
10
modules/frontend/src/api_/models/Image.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
13
modules/frontend/src/api_/models/ReleaseSeason.ts
Normal file
13
modules/frontend/src/api_/models/ReleaseSeason.ts
Normal 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',
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
||||||
12
modules/frontend/src/api_/models/Studio.ts
Normal file
12
modules/frontend/src/api_/models/Studio.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
8
modules/frontend/src/api_/models/Tag.ts
Normal file
8
modules/frontend/src/api_/models/Tag.ts
Normal 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>;
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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;
|
|
||||||
12
modules/frontend/src/api_/models/TitleStatus.ts
Normal file
12
modules/frontend/src/api_/models/TitleStatus.ts
Normal 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',
|
||||||
|
}
|
||||||
35
modules/frontend/src/api_/models/User.ts
Normal file
35
modules/frontend/src/api_/models/User.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
5
modules/frontend/src/api_/models/UserTitle.ts
Normal file
5
modules/frontend/src/api_/models/UserTitle.ts
Normal 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>;
|
||||||
13
modules/frontend/src/api_/models/UserTitleStatus.ts
Normal file
13
modules/frontend/src/api_/models/UserTitleStatus.ts
Normal 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',
|
||||||
|
}
|
||||||
5
modules/frontend/src/api_/models/cursor.ts
Normal file
5
modules/frontend/src/api_/models/cursor.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type cursor = string;
|
||||||
148
modules/frontend/src/api_/services/DefaultService.ts
Normal file
148
modules/frontend/src/api_/services/DefaultService.ts
Normal 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`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
37
modules/frontend/src/components/ListView/useListView.tsx
Normal file
37
modules/frontend/src/components/ListView/useListView.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue