Files
pokopiawiki.tootaio.com/frontend/src/services/api.ts
xiaomai 3d99f00c75 feat(wiki): add event item flag and decouple pokemon display ID
Add `is_event_item` to pokemon, items, and habitats.
Separate internal `id` and `display_id` for pokemon to allow event variants.
Update frontend forms and views to support the new fields.
2026-05-03 10:11:04 +08:00

741 lines
22 KiB
TypeScript

import { getCurrentLocale } from '../i18n';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change';
export type TranslationField = 'name' | 'title' | 'details' | 'genus';
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
export interface Language {
code: string;
name: string;
enabled: boolean;
isDefault: boolean;
sortOrder: number;
}
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
export interface SystemWording {
key: string;
module: string;
surface: SystemWordingSurface;
description: string;
placeholders: string[];
value: string;
defaultValue: string;
missing: boolean;
updatedAt: string | null;
updatedBy: UserSummary | null;
}
export interface NamedEntity {
id: number;
name: string;
baseName?: string;
translations?: TranslationMap;
}
export interface Skill extends NamedEntity {
hasItemDrop: boolean;
}
export interface PokemonStats {
hp: number;
attack: number;
defense: number;
specialAttack: number;
specialDefense: number;
speed: number;
}
export interface UserSummary {
id: number;
displayName: string;
}
export interface EntityImage {
path: string;
url: string;
}
export interface EntityImageUpload extends EntityImage {
id: number;
entityType: ImageUploadEntityType;
entityId: number | null;
entityName: string;
originalFilename: string;
mimeType: string;
byteSize: number;
uploadedAt: string;
uploadedBy: UserSummary | null;
}
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats';
export interface PokemonImage extends EntityImage {
style: string;
version: string;
variant: string;
description: string;
source?: 'sprite' | 'upload';
}
export interface EditInfo {
createdAt: string;
updatedAt: string;
createdBy: UserSummary | null;
updatedBy: UserSummary | null;
}
export type EditHistoryAction = 'create' | 'update' | 'delete';
export interface EditChange {
label: string;
before: string;
after: string;
}
export interface EditHistoryEntry {
action: EditHistoryAction;
changes: EditChange[];
createdAt: string;
user: UserSummary | null;
}
export interface Pokemon extends EditInfo {
id: number;
displayId: number;
name: string;
baseName?: string;
isEventItem: boolean;
genus: string;
baseGenus?: string;
details: string;
baseDetails?: string;
heightInches: number;
heightMeters: number;
weightPounds: number;
weightKg: number;
image: PokemonImage | null;
translations?: TranslationMap;
types: NamedEntity[];
stats: PokemonStats;
environment: NamedEntity;
skills: Skill[];
favorite_things: NamedEntity[];
}
export interface RelatedPokemon {
id: number;
displayId: number;
name: string;
isEventItem: boolean;
image?: PokemonImage | null;
environment: NamedEntity;
skills: Skill[];
favorite_things: Array<NamedEntity & { matches: boolean }>;
}
export interface PokemonDetail extends Pokemon {
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
habitats: Array<{
id: number;
name: string;
image?: EntityImage | null;
time_of_day: string;
weather: string;
rarity: number;
map: NamedEntity;
}>;
}
export interface Habitat extends EditInfo {
id: number;
name: string;
baseName?: string;
isEventItem: boolean;
translations?: TranslationMap;
image: EntityImage | null;
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
pokemon?: NamedEntity[];
}
export interface HabitatDetail extends Habitat {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
pokemon: Array<NamedEntity & {
displayId: number;
isEventItem: boolean;
image?: PokemonImage | null;
time_of_day: string;
weather: string;
rarity: number;
map: NamedEntity;
}>;
}
export interface RecipeSummary extends EditInfo {
id: number;
}
export interface RecipeUsage {
id: number;
name: string;
image?: EntityImage | null;
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface HabitatUsage {
id: number;
name: string;
image?: EntityImage | null;
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface RecipeResultItem extends NamedEntity {
image?: EntityImage | null;
category?: NamedEntity;
usage?: NamedEntity | null;
}
export interface Item extends EditInfo {
id: number;
name: string;
baseName?: string;
isEventItem: boolean;
translations?: TranslationMap;
image: EntityImage | null;
category: NamedEntity;
usage: NamedEntity | null;
customization: {
dyeable: boolean;
dualDyeable: boolean;
patternEditable: boolean;
};
noRecipe: boolean;
tags: NamedEntity[];
recipe: RecipeSummary | null;
}
export interface ItemDetail extends Item {
acquisitionMethods: NamedEntity[];
recipe: RecipeDetail | null;
relatedRecipes: RecipeUsage[];
relatedHabitats: HabitatUsage[];
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
droppedByPokemon: Array<{
pokemon: NamedEntity & { displayId: number; isEventItem: boolean; image?: PokemonImage | null };
skill: NamedEntity;
}>;
}
export interface Recipe extends EditInfo {
id: number;
name: string;
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface DailyChecklistItem {
id: number;
title: string;
baseTitle?: string;
translations?: TranslationMap;
}
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
export type LifeReactionCounts = Record<LifeReactionType, number>;
export interface LifePost {
id: number;
body: string;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
updatedBy: UserSummary | null;
tags: NamedEntity[];
comments: LifeComment[];
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
}
export interface LifePostsPage {
items: LifePost[];
nextCursor: string | null;
hasMore: boolean;
}
export interface LifePostsParams {
cursor?: string | null;
limit?: number;
search?: string;
tagId?: string | number;
}
export interface LifeComment {
id: number;
postId: number;
parentCommentId: number | null;
body: string;
deleted: boolean;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
replies: LifeComment[];
}
export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[];
editHistory: EditHistoryEntry[];
item: RecipeResultItem;
}
export interface Options {
pokemonTypes: NamedEntity[];
skills: Skill[];
environments: NamedEntity[];
favoriteThings: NamedEntity[];
itemCategories: NamedEntity[];
itemUsages: NamedEntity[];
acquisitionMethods: NamedEntity[];
itemTags: NamedEntity[];
maps: NamedEntity[];
lifeTags: NamedEntity[];
}
export interface AuthUser {
id: number;
email: string;
displayName: string;
emailVerified: boolean;
}
export interface UserProfilePayload {
displayName: string;
}
export interface LoginPayload {
email: string;
password: string;
rememberMe?: boolean;
}
export interface RegisterPayload extends LoginPayload {
displayName: string;
}
export interface AuthResponse {
token: string;
user: AuthUser;
}
export type ConfigType =
| 'pokemon-types'
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags';
export interface PokemonPayload {
displayId: number;
isEventItem: boolean;
name: string;
genus: string;
details: string;
heightInches: number;
weightPounds: number;
translations?: TranslationMap;
typeIds: number[];
stats: PokemonStats;
environmentId: number;
skillIds: number[];
favoriteThingIds: number[];
skillItemDrops: Array<{ skillId: number; itemId: number }>;
imagePath: string;
}
export interface PokemonFetchResult {
id: number;
identifier: string;
name: string;
genus: string;
heightInches: number;
weightPounds: number;
translations?: TranslationMap;
typeIds: number[];
stats: PokemonStats;
}
export interface PokemonFetchOption {
id: number;
identifier: string;
name: string;
}
export interface PokemonImageOptionsResult {
id: number;
identifier: string;
images: PokemonImage[];
}
export interface ItemPayload {
name: string;
translations?: TranslationMap;
categoryId: number;
usageId: number | null;
dyeable: boolean;
dualDyeable: boolean;
patternEditable: boolean;
noRecipe: boolean;
isEventItem: boolean;
acquisitionMethodIds: number[];
tagIds: number[];
imagePath: string;
}
export interface RecipePayload {
itemId: number;
acquisitionMethodIds: number[];
materials: Array<{ itemId: number; quantity: number }>;
}
export interface HabitatPayload {
name: string;
translations?: TranslationMap;
isEventItem: boolean;
imagePath: string;
recipeItems: Array<{ itemId: number; quantity: number }>;
pokemonAppearances: Array<{
pokemonId: number;
mapIds: number[];
timeOfDays: string[];
weathers: string[];
rarity: number;
}>;
}
export interface DailyChecklistPayload {
title: string;
translations?: TranslationMap;
}
export interface LifePostPayload {
body: string;
tagIds?: number[];
}
export interface LifeCommentPayload {
body: string;
}
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
export interface EntityDiscussionComment {
id: number;
entityType: DiscussionEntityType;
entityId: number;
parentCommentId: number | null;
body: string;
deleted: boolean;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
replies: EntityDiscussionComment[];
}
export interface EntityDiscussionCommentPayload {
body: string;
}
export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
search.set(key, String(value));
}
});
const query = search.toString();
return query ? `?${query}` : '';
}
function authStorage(type: 'local' | 'session'): Storage | null {
if (typeof window === 'undefined') {
return null;
}
return type === 'local' ? window.localStorage : window.sessionStorage;
}
export function getAuthToken(): string | null {
const sessionToken = authStorage('session')?.getItem(authTokenKey);
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
}
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
const local = authStorage('local');
const session = authStorage('session');
if (token) {
if (options.persistent === false) {
session?.setItem(authTokenKey, token);
local?.removeItem(authTokenKey);
} else {
local?.setItem(authTokenKey, token);
session?.removeItem(authTokenKey);
}
} else {
local?.removeItem(authTokenKey);
session?.removeItem(authTokenKey);
}
notifyAuthChange();
}
export function onAuthTokenChange(callback: () => void): () => void {
window.addEventListener(authChangeEvent, callback);
return () => window.removeEventListener(authChangeEvent, callback);
}
export function notifyAuthChange(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event(authChangeEvent));
}
}
function requestHeaders(): HeadersInit {
const token = getAuthToken();
return {
'X-Locale': getCurrentLocale(),
...(token ? { Authorization: `Bearer ${token}` } : {})
};
}
async function getErrorMessage(response: Response): Promise<string> {
try {
const data = (await response.json()) as { message?: unknown };
if (typeof data.message === 'string' && data.message.trim() !== '') {
return data.message;
}
} catch {
// Ignore invalid or empty error bodies and use the status fallback.
}
return `Request failed (${response.status})`;
}
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
headers: requestHeaders(),
signal
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
return response.json() as Promise<T>;
}
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...requestHeaders()
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
return response.json() as Promise<T>;
}
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: requestHeaders(),
body
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
return response.json() as Promise<T>;
}
async function postEmpty(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: requestHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
}
async function deleteJson(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE',
headers: requestHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
}
async function deleteAndGetJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE',
headers: requestHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
return response.json() as Promise<T>;
}
export const api = {
languages: () => getJson<Language[]>('/api/languages'),
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
updateLanguage: (code: string, payload: Partial<Language> & { name: string }) =>
sendJson<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
deleteLanguage: (code: string) => deleteJson(`/api/admin/languages/${code}`),
systemWordings: (params: { locale?: string; module?: string; surface?: string; missing?: string } = {}) =>
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
requestPasswordReset: (payload: { email: string }) =>
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
resetPassword: (payload: { token: string; password: string }) =>
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
logout: () => postEmpty('/api/auth/logout'),
options: () => getJson<Options>('/api/options'),
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
lifePosts: (params: LifePostsParams = {}) =>
getJson<LifePostsPage>(
`/api/life-posts${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
search: params.search?.trim(),
tagId: params.tagId
})}`
),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
updateLifePost: (id: string | number, payload: LifePostPayload) =>
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
createEntityDiscussionComment: (
entityType: DiscussionEntityType,
entityId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments`, 'POST', payload),
createEntityDiscussionReply: (
entityType: DiscussionEntityType,
entityId: string | number,
commentId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
uploadImage: (
entityType: ImageUploadEntityType,
payload: { file: File; entityName: string; entityId?: string | number | null }
) => {
const body = new FormData();
body.set('entityName', payload.entityName);
if (payload.entityId) {
body.set('entityId', String(payload.entityId));
}
body.set('file', payload.file);
return sendFormData<EntityImageUpload>(`/api/uploads/${entityType}`, body);
},
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
sendJson<DailyChecklistItem>(`/api/admin/daily-checklist/${id}`, 'PUT', payload),
reorderDailyChecklistItems: (ids: number[]) =>
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) =>
sendJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
fetchPokemonImageOptions: (identifier: string) =>
sendJson<PokemonImageOptionsResult>('/api/pokemon/image-options', 'POST', { identifier }),
createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload),
updatePokemon: (id: string | number, payload: PokemonPayload) =>
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
habitats: () => getJson<Habitat[]>('/api/habitats'),
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
updateHabitat: (id: string | number, payload: HabitatPayload) =>
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
items: (params: Record<string, string | number | undefined>) =>
getJson<Item[]>(`/api/items${buildQuery(params)}`),
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
recipes: (params: Record<string, string | number | undefined> = {}) =>
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
updateRecipe: (id: string | number, payload: RecipePayload) =>
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`),
reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids })
};