add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add Call of Duty tracking for Black Ops through WWII. - Pokémon catalog is now owned: locations, encounters and per-game evolution methods are built from the pinned PokéAPI snapshot and seeded. - Pokémon you can evolve or breed in a game count as obtainable; mythicals without another route are marked event-only. - Detail dialog with evolution chain and where to find it, location pages with admin notes and pictures, and admin corrections that survive reseeds. - Call of Duty weapons, milestones and Steam achievements per game, with derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers. - New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and deploy preflight checks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
const catalog = JSON.parse(readFileSync('data/pokemon-catalog.json', 'utf8')) as {
|
||||
pokemon: { number: number; name: string; hp: number; type1: string }[];
|
||||
games: { sourceId: number; name: string }[];
|
||||
entries: [number, number, string][];
|
||||
pokemon: {
|
||||
number: number;
|
||||
name: string;
|
||||
hp: number;
|
||||
type1: string;
|
||||
evolvesFrom: number | null;
|
||||
}[];
|
||||
games: { sourceId: number; name: string; slug: string }[];
|
||||
entries: [number, number, string, string?][];
|
||||
evolutions: [number, number, number[], string][];
|
||||
};
|
||||
const places = JSON.parse(readFileSync('data/pokemon-locations.json', 'utf8')) as {
|
||||
locations: { sourceId: number; name: string }[];
|
||||
methods: string[];
|
||||
conditions: string[];
|
||||
encounters: [number, number, number, string | null, number, number, number, ...unknown[]][];
|
||||
};
|
||||
const game = (slug: string) => catalog.games.find((g) => g.slug === slug)!.sourceId;
|
||||
const entry = (number: number, slug: string) =>
|
||||
catalog.entries.find(([p, g]) => p === number && g === game(slug));
|
||||
|
||||
describe('Pokémon catalog snapshot', () => {
|
||||
it('contains the national species catalog and the latest source versions', () => {
|
||||
expect(catalog.pokemon).toHaveLength(1025);
|
||||
@@ -12,7 +29,8 @@ describe('Pokémon catalog snapshot', () => {
|
||||
expect(catalog.pokemon.find((p) => p.number === 25)).toMatchObject({
|
||||
name: 'Pikachu',
|
||||
type1: 'Electric',
|
||||
hp: 35
|
||||
hp: 35,
|
||||
evolvesFrom: 172
|
||||
});
|
||||
expect(catalog.games.some((g) => g.name.includes('Z-A'))).toBe(true);
|
||||
});
|
||||
@@ -24,12 +42,62 @@ describe('Pokémon catalog snapshot', () => {
|
||||
expect(new Set(catalog.entries.map(([p, g]) => `${p}/${g}`)).size).toBe(catalog.entries.length);
|
||||
expect(
|
||||
catalog.entries.every(
|
||||
([p, g, a]) => pokemon.has(p) && games.has(g) && ['catchable', 'unknown'].includes(a)
|
||||
([p, g, a]) =>
|
||||
pokemon.has(p) &&
|
||||
games.has(g) &&
|
||||
['catchable', 'unknown', 'evolve', 'breed', 'event_only'].includes(a)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
it('does not invent exclusive availability from missing encounters', () => {
|
||||
expect(catalog.entries.some(([, , a]) => a === 'catchable')).toBe(true);
|
||||
expect(catalog.entries.some(([, , a]) => a === 'unknown')).toBe(true);
|
||||
expect(catalog.entries.some(([, , a]) => a === 'trade_only' || a === 'transfer_only')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
it('derives evolution, breeding and event availability per game', () => {
|
||||
expect(entry(9, 'red')).toEqual([9, game('red'), 'evolve', 'Evolve Wartortle: Level 36+']);
|
||||
expect(entry(172, 'gold')).toEqual([172, game('gold'), 'breed', 'Breed Pikachu']);
|
||||
expect(entry(298, 'emerald')?.[3]).toBe('Breed Marill holding a Sea Incense');
|
||||
expect(entry(151, 'firered')?.[2]).toBe('event_only');
|
||||
// the evolution method depends on the game
|
||||
expect(entry(462, 'platinum')?.[3]).toBe('Evolve Magneton: Level up at Mt. Coronet');
|
||||
expect(entry(462, 'heartgold')?.[2]).toBe('unknown');
|
||||
expect(entry(26, 'sun')?.[3]).toBe('Evolve Pikachu: Use a Thunder Stone in Alola');
|
||||
// no breeding in Let's Go
|
||||
expect(catalog.entries.some(([, g, a]) => g === game('lets-go-pikachu') && a === 'breed')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
it('keeps evolution methods per game for present species only', () => {
|
||||
const present = new Set(catalog.entries.map(([p, g]) => `${p}/${g}`));
|
||||
for (const [from, to, versions] of catalog.evolutions)
|
||||
for (const version of versions) {
|
||||
expect(present.has(`${from}/${version}`)).toBe(true);
|
||||
expect(present.has(`${to}/${version}`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pokémon locations snapshot', () => {
|
||||
it('references known species, games, locations, methods and conditions', () => {
|
||||
const pokemon = new Set(catalog.pokemon.map((p) => p.number));
|
||||
const games = new Set(catalog.games.map((g) => g.sourceId));
|
||||
const locations = new Set(places.locations.map((l) => l.sourceId));
|
||||
expect(places.locations.length).toBeGreaterThan(1000);
|
||||
expect(places.encounters.length).toBeGreaterThan(60000);
|
||||
for (const [p, g, l, , method, min, max, , , conditions] of places.encounters) {
|
||||
expect(pokemon.has(p) && games.has(g) && locations.has(l)).toBe(true);
|
||||
expect(places.methods[method]).toBeTruthy();
|
||||
expect(min).toBeLessThanOrEqual(max);
|
||||
for (const index of conditions as number[]) expect(places.conditions[index]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
it('marks every species/game pair with an encounter as catchable', () => {
|
||||
const catchable = new Set(
|
||||
catalog.entries.filter(([, , a]) => a === 'catchable').map(([p, g]) => `${p}/${g}`)
|
||||
);
|
||||
for (const [p, g] of places.encounters) expect(catchable.has(`${p}/${g}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,25 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
entries: r.many.pokemonGameEntries()
|
||||
},
|
||||
pokemon: {
|
||||
entry: r.many.pokemonGameEntries()
|
||||
entry: r.many.pokemonGameEntries(),
|
||||
encounters: r.many.pokemonEncounters()
|
||||
},
|
||||
pokemonLocations: {
|
||||
encounters: r.many.pokemonEncounters()
|
||||
},
|
||||
pokemonEncounters: {
|
||||
pokemon: r.one.pokemon({
|
||||
from: r.pokemonEncounters.pokemonId,
|
||||
to: r.pokemon.id
|
||||
}),
|
||||
game: r.one.games({
|
||||
from: r.pokemonEncounters.gameId,
|
||||
to: r.games.id
|
||||
}),
|
||||
location: r.one.pokemonLocations({
|
||||
from: r.pokemonEncounters.locationId,
|
||||
to: r.pokemonLocations.id
|
||||
})
|
||||
},
|
||||
pokemonGameEntries: {
|
||||
game: r.one.games({
|
||||
@@ -80,6 +98,28 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
})
|
||||
},
|
||||
|
||||
// ── Game tracker ──────────────────────────────
|
||||
trackerGames: {
|
||||
items: r.many.trackerItems()
|
||||
},
|
||||
trackerItems: {
|
||||
game: r.one.trackerGames({
|
||||
from: r.trackerItems.gameId,
|
||||
to: r.trackerGames.id
|
||||
}),
|
||||
progress: r.many.trackerProgress()
|
||||
},
|
||||
trackerProgress: {
|
||||
item: r.one.trackerItems({
|
||||
from: r.trackerProgress.itemId,
|
||||
to: r.trackerItems.id
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.trackerProgress.userId,
|
||||
to: r.user.id
|
||||
})
|
||||
},
|
||||
|
||||
// ── Animaldex ─────────────────────────────────
|
||||
animals: {
|
||||
sightings: r.many.sightings()
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { boolean, index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { availabilityValues } from '../../../availability';
|
||||
import { user } from './auth';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
export const availability = pgEnum('availability', availabilityValues);
|
||||
|
||||
// Where a catalog row came from. Seeding replaces `pokeapi` and `derived` rows
|
||||
// but never touches `manual` ones, which are admin corrections.
|
||||
export const catalogSources = ['pokeapi', 'derived', 'manual'] as const;
|
||||
const source = () => text('source', { enum: catalogSources }).notNull().default('pokeapi');
|
||||
|
||||
export const pokemon = pgTable('pokemon', {
|
||||
id: id(),
|
||||
number: integer('number').notNull().unique(),
|
||||
@@ -18,14 +23,23 @@ export const pokemon = pgTable('pokemon', {
|
||||
specialAttack: integer('special_attack').notNull().default(0),
|
||||
specialDefense: integer('special_defense').notNull().default(0),
|
||||
speed: integer('speed').notNull().default(0),
|
||||
// national numbers, so the chain survives reseeding without id lookups
|
||||
evolvesFrom: integer('evolves_from'),
|
||||
evolutionChain: integer('evolution_chain'),
|
||||
isBaby: boolean('is_baby').notNull().default(false),
|
||||
isLegendary: boolean('is_legendary').notNull().default(false),
|
||||
isMythical: boolean('is_mythical').notNull().default(false),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const games = pgTable('games', {
|
||||
id: id(),
|
||||
sourceId: integer('source_id').unique(),
|
||||
slug: text('slug').unique(),
|
||||
name: text('name').notNull(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
breeding: boolean('breeding').notNull().default(true),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
@@ -40,13 +54,81 @@ export const pokemonGameEntries = pgTable(
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
availability: availability('availability').notNull().default('catchable'),
|
||||
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
|
||||
// how to obtain it, e.g. "Evolve Wartortle: Level 36+" or "Route 22, Viridian Forest"
|
||||
note: text('note'),
|
||||
source: source(),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
|
||||
);
|
||||
|
||||
export const pokemonLocations = pgTable('pokemon_locations', {
|
||||
id: id(),
|
||||
sourceId: integer('source_id').unique(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
region: text('region'),
|
||||
description: text('description'),
|
||||
// R2 object key of an admin-uploaded picture
|
||||
imageKey: text('image_key'),
|
||||
imageCredit: text('image_credit'),
|
||||
source: source(),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const pokemonEncounters = pgTable(
|
||||
'pokemon_encounters',
|
||||
{
|
||||
id: id(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
locationId: integer('location_id')
|
||||
.notNull()
|
||||
.references(() => pokemonLocations.id, { onDelete: 'cascade' }),
|
||||
area: text('area'),
|
||||
method: text('method').notNull(),
|
||||
minLevel: integer('min_level'),
|
||||
maxLevel: integer('max_level'),
|
||||
// summed encounter-slot percentage; null for gifts and folded raids
|
||||
rarity: integer('rarity'),
|
||||
form: text('form'),
|
||||
conditions: text('conditions').array().notNull().default([]),
|
||||
note: text('note'),
|
||||
source: source(),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index().on(table.pokemonId, table.gameId), index().on(table.locationId)]
|
||||
);
|
||||
|
||||
// The method that applies in each game, e.g. Magneton → Magnezone needs Mt.
|
||||
// Coronet in Platinum but a Thunder Stone in Sword.
|
||||
export const pokemonEvolutions = pgTable(
|
||||
'pokemon_evolutions',
|
||||
{
|
||||
id: id(),
|
||||
fromPokemonId: integer('from_pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
toPokemonId: integer('to_pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
method: text('method').notNull(),
|
||||
source: source(),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [
|
||||
unique().on(table.fromPokemonId, table.toPokemonId, table.gameId, table.method),
|
||||
index().on(table.gameId)
|
||||
]
|
||||
);
|
||||
|
||||
export const userEntries = pgTable(
|
||||
'user_entries',
|
||||
{
|
||||
@@ -94,3 +176,6 @@ export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
|
||||
export type UserEntry = typeof userEntries.$inferSelect;
|
||||
export type UserGame = typeof userGames.$inferSelect;
|
||||
export type Availability = (typeof availability.enumValues)[number];
|
||||
export type PokemonLocation = typeof pokemonLocations.$inferSelect;
|
||||
export type PokemonEncounter = typeof pokemonEncounters.$inferSelect;
|
||||
export type PokemonEvolution = typeof pokemonEvolutions.$inferSelect;
|
||||
|
||||
61
src/lib/server/db/schema/game-tracker.ts
Normal file
61
src/lib/server/db/schema/game-tracker.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { boolean, index, integer, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import type { MasteryRules } from '../../../game-tracker/mastery';
|
||||
import { user } from './auth';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
// Checklist-style games (Call of Duty and future franchises). Pokémon keeps its
|
||||
// own species × game model in catch-em-all.ts.
|
||||
export const trackerGames = pgTable('tracker_games', {
|
||||
id: id(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
franchise: text('franchise').notNull(),
|
||||
name: text('name').notNull(),
|
||||
shortName: text('short_name').notNull(),
|
||||
releaseYear: integer('release_year'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
rules: jsonb('rules').$type<MasteryRules>().notNull(),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const trackerItems = pgTable(
|
||||
'tracker_items',
|
||||
{
|
||||
id: id(),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => trackerGames.id, { onDelete: 'cascade' }),
|
||||
// stable catalog key, e.g. `weapon:m8a7` or `311210:the-true-gunslinger`
|
||||
key: text('key').notNull(),
|
||||
kind: text('kind', { enum: ['achievement', 'weapon'] }).notNull(),
|
||||
// achievement mode or weapon class
|
||||
category: text('category').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
icon: text('icon'),
|
||||
postLaunch: boolean('post_launch').notNull().default(false),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.gameId, table.key), index().on(table.gameId)]
|
||||
);
|
||||
|
||||
// One row per completed milestone: `done` for achievements, or a weapon
|
||||
// milestone key such as `gold` or `maxLevel`. Derived tiers are never stored.
|
||||
export const trackerProgress = pgTable(
|
||||
'tracker_progress',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => trackerItems.id, { onDelete: 'cascade' }),
|
||||
milestone: text('milestone').notNull().default('done'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.userId, table.itemId, table.milestone), index().on(table.userId)]
|
||||
);
|
||||
|
||||
export type TrackerGame = typeof trackerGames.$inferSelect;
|
||||
export type TrackerItem = typeof trackerItems.$inferSelect;
|
||||
@@ -4,6 +4,7 @@ export * from './auth';
|
||||
export * from './blog';
|
||||
export * from './progress';
|
||||
export * from './catch-em-all';
|
||||
export * from './game-tracker';
|
||||
export * from './animaldex';
|
||||
export * from './lists';
|
||||
export * from './money-tracker';
|
||||
|
||||
137
src/lib/server/pokemon-detail.ts
Normal file
137
src/lib/server/pokemon-detail.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
games,
|
||||
pokemon,
|
||||
pokemonEncounters,
|
||||
pokemonEvolutions,
|
||||
pokemonGameEntries,
|
||||
pokemonLocations
|
||||
} from '$lib/server/db/schema';
|
||||
|
||||
export type PokemonDetail = Awaited<ReturnType<typeof pokemonDetail>>;
|
||||
|
||||
// Everything the detail panel shows for one species, scoped to a game when given.
|
||||
export async function pokemonDetail(number: number, gameId: number | null) {
|
||||
const [species] = await db.select().from(pokemon).where(eq(pokemon.number, number));
|
||||
if (!species) error(404, 'Unknown Pokémon');
|
||||
const chain = await db
|
||||
.select({
|
||||
id: pokemon.id,
|
||||
number: pokemon.number,
|
||||
name: pokemon.name,
|
||||
evolvesFrom: pokemon.evolvesFrom
|
||||
})
|
||||
.from(pokemon)
|
||||
.where(eq(pokemon.evolutionChain, species.evolutionChain ?? -1))
|
||||
.orderBy(asc(pokemon.number));
|
||||
const members = chain.length ? chain : [species];
|
||||
const entries = await db
|
||||
.select({
|
||||
id: pokemonGameEntries.id,
|
||||
gameId: pokemonGameEntries.gameId,
|
||||
game: games.name,
|
||||
availability: pokemonGameEntries.availability,
|
||||
note: pokemonGameEntries.note,
|
||||
source: pokemonGameEntries.source
|
||||
})
|
||||
.from(pokemonGameEntries)
|
||||
.innerJoin(games, eq(games.id, pokemonGameEntries.gameId))
|
||||
.where(eq(pokemonGameEntries.pokemonId, species.id))
|
||||
.orderBy(asc(games.generation), asc(games.id));
|
||||
|
||||
// which chain members exist in the selected game at all
|
||||
const present = gameId
|
||||
? await db
|
||||
.select({ id: pokemonGameEntries.pokemonId })
|
||||
.from(pokemonGameEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonGameEntries.gameId, gameId),
|
||||
inArray(
|
||||
pokemonGameEntries.pokemonId,
|
||||
members.map((member) => member.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const presentIds = new Set(present.map((row) => row.id));
|
||||
const evolutions = gameId
|
||||
? await db
|
||||
.select({
|
||||
from: pokemonEvolutions.fromPokemonId,
|
||||
to: pokemonEvolutions.toPokemonId,
|
||||
method: pokemonEvolutions.method
|
||||
})
|
||||
.from(pokemonEvolutions)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonEvolutions.gameId, gameId),
|
||||
inArray(
|
||||
pokemonEvolutions.toPokemonId,
|
||||
members.map((member) => member.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const encounters = gameId
|
||||
? await db
|
||||
.select({
|
||||
locationId: pokemonLocations.id,
|
||||
location: pokemonLocations.name,
|
||||
region: pokemonLocations.region,
|
||||
hasPicture: pokemonLocations.imageKey,
|
||||
area: pokemonEncounters.area,
|
||||
method: pokemonEncounters.method,
|
||||
minLevel: pokemonEncounters.minLevel,
|
||||
maxLevel: pokemonEncounters.maxLevel,
|
||||
rarity: pokemonEncounters.rarity,
|
||||
form: pokemonEncounters.form,
|
||||
conditions: pokemonEncounters.conditions,
|
||||
note: pokemonEncounters.note
|
||||
})
|
||||
.from(pokemonEncounters)
|
||||
.innerJoin(pokemonLocations, eq(pokemonLocations.id, pokemonEncounters.locationId))
|
||||
.where(
|
||||
and(eq(pokemonEncounters.pokemonId, species.id), eq(pokemonEncounters.gameId, gameId))
|
||||
)
|
||||
.orderBy(
|
||||
asc(pokemonLocations.name),
|
||||
asc(pokemonEncounters.area),
|
||||
asc(pokemonEncounters.method),
|
||||
asc(pokemonEncounters.minLevel)
|
||||
)
|
||||
: [];
|
||||
|
||||
const numbers = new Map(members.map((member) => [member.id, member.number]));
|
||||
return {
|
||||
pokemon: {
|
||||
number: species.number,
|
||||
name: species.name,
|
||||
generation: species.generation,
|
||||
type1: species.type1,
|
||||
type2: species.type2,
|
||||
isBaby: species.isBaby,
|
||||
isLegendary: species.isLegendary,
|
||||
isMythical: species.isMythical
|
||||
},
|
||||
chain: members.map(({ id, number, name, evolvesFrom }) => ({
|
||||
number,
|
||||
name,
|
||||
evolvesFrom,
|
||||
inGame: presentIds.has(id)
|
||||
})),
|
||||
evolutions: evolutions.map((evolution) => ({
|
||||
from: numbers.get(evolution.from)!,
|
||||
to: numbers.get(evolution.to)!,
|
||||
method: evolution.method
|
||||
})),
|
||||
entry: entries.find((entry) => entry.gameId === gameId) ?? null,
|
||||
games: entries.map(({ gameId, game, availability }) => ({ gameId, game, availability })),
|
||||
encounters: encounters.map(({ hasPicture, ...encounter }) => ({
|
||||
...encounter,
|
||||
hasPicture: !!hasPicture
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -46,7 +46,7 @@ const EXT: Record<AllowedMimeType, string> = {
|
||||
/** Upload through the site server so browsers do not need R2 CORS access. */
|
||||
export async function uploadImage(
|
||||
file: File,
|
||||
prefix: 'sightings' | 'lists' = 'sightings'
|
||||
prefix: 'sightings' | 'lists' | 'locations' = 'sightings'
|
||||
): Promise<{ key: string; publicUrl: string }> {
|
||||
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
|
||||
const mimeType = file.type;
|
||||
@@ -73,6 +73,13 @@ export function listImageUrl(key: string): string | null {
|
||||
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
|
||||
/** Public URL of an uploaded Pokémon location picture, or null for foreign keys. */
|
||||
export function locationImageUrl(key: string | null): string | null {
|
||||
if (!key || !/^locations\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) || !env.R2_PUBLIC_URL)
|
||||
return null;
|
||||
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
|
||||
export function listImageKey(url: string): string | null {
|
||||
const base = env.R2_PUBLIC_URL?.replace(/\/$/, '');
|
||||
if (!base || !url.startsWith(`${base}/`)) return null;
|
||||
|
||||
Reference in New Issue
Block a user