feat(items): add dye previews support

Add item_dye_previews table to store color preview images per dyeable part
Update item detail and edit views to support managing dye previews
This commit is contained in:
2026-05-13 17:06:49 +08:00
parent c15905bafd
commit a42c8ef5c8
8 changed files with 400 additions and 14 deletions

View File

@@ -1218,6 +1218,22 @@ CREATE TABLE IF NOT EXISTS items (
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road', 'food'))
);
CREATE TABLE IF NOT EXISTS item_dye_previews (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
part_index integer NOT NULL CHECK (part_index BETWEEN 1 AND 3),
color_name text NOT NULL,
image_path text NOT NULL,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
CHECK (length(color_name) BETWEEN 1 AND 80)
);
CREATE UNIQUE INDEX IF NOT EXISTS item_dye_previews_item_part_color_idx
ON item_dye_previews(item_id, part_index, lower(color_name));
CREATE INDEX IF NOT EXISTS item_dye_previews_item_order_idx
ON item_dye_previews(item_id, part_index, sort_order, id);
CREATE TABLE IF NOT EXISTS recipes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
item_id integer NOT NULL UNIQUE REFERENCES items(id),
@@ -1557,6 +1573,22 @@ ALTER TABLE skills
ALTER TABLE items
ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3));
CREATE TABLE IF NOT EXISTS item_dye_previews (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
part_index integer NOT NULL CHECK (part_index BETWEEN 1 AND 3),
color_name text NOT NULL,
image_path text NOT NULL,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
CHECK (length(color_name) BETWEEN 1 AND 80)
);
CREATE UNIQUE INDEX IF NOT EXISTS item_dye_previews_item_part_color_idx
ON item_dye_previews(item_id, part_index, lower(color_name));
CREATE INDEX IF NOT EXISTS item_dye_previews_item_order_idx
ON item_dye_previews(item_id, part_index, sort_order, id);
ALTER TABLE environments
ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';

View File

@@ -284,10 +284,18 @@ type ItemPayload = {
acquisitionMethodIds: number[];
tagIds: number[];
imagePath: string;
dyePreviews: ItemDyePreviewPayload[];
insertBeforeItemId: number | null;
insertAfterItemId: number | null;
};
type ItemDyePreviewPayload = {
partIndex: number;
colorName: string;
imagePath: string;
sortOrder: number;
};
type AncientArtifactPayload = {
name: string;
details: string;
@@ -620,6 +628,7 @@ type ItemChangeSource = {
noRecipe: boolean;
acquisitionMethods: Array<{ name: string }>;
tags: Array<{ name: string }>;
dyePreviews: Array<{ partIndex: number; colorName: string; image: EntityImageValue | null }>;
} & TranslationChangeSource;
type AncientArtifactChangeSource = {
name: string;
@@ -2595,6 +2604,7 @@ async function itemEditChanges(
pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe));
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
pushChange(changes, 'Dye previews', dyePreviewListValue(before.dyePreviews), dyePreviewListValue(after.dyePreviews));
return changes;
}
@@ -6835,6 +6845,7 @@ export async function getItem(id: number, locale = defaultLocale) {
droppedByPokemon,
allPossibleTags,
possibleTagObservations,
dyePreviews,
editHistory,
imageHistory
] = await Promise.all([
@@ -7008,12 +7019,39 @@ export async function getItem(id: number, locale = defaultLocale) {
[id]
)
: Promise.resolve([]),
listItemDyePreviews(id),
getEditHistory('items', id),
listEntityImageUploads('items', id)
]);
const possibleTags = inferItemPossibleTags(allPossibleTags, possibleTagObservations);
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, possibleTags, editHistory, imageHistory };
return {
...item,
acquisitionMethods,
recipe,
relatedRecipes,
relatedHabitats,
droppedByPokemon,
possibleTags,
dyePreviews,
editHistory,
imageHistory
};
}
async function listItemDyePreviews(itemId: number) {
return query(
`
SELECT
idp.part_index AS "partIndex",
idp.color_name AS "colorName",
${uploadedImageJson('idp.image_path')} AS image
FROM item_dye_previews idp
WHERE idp.item_id = $1
ORDER BY idp.part_index, idp.sort_order, idp.id
`,
[itemId]
);
}
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
@@ -7035,6 +7073,8 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
throw validationError('server.validation.invalidField');
}
const dyeability = cleanDyeability(payload);
return {
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
details: cleanOptionalText(payload.details),
@@ -7046,13 +7086,14 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
categoryKey: category.key,
usageId,
usageKey: usage?.key ?? null,
dyeability: cleanDyeability(payload),
dyeability,
patternEditable: Boolean(payload.patternEditable),
noRecipe: Boolean(payload.noRecipe),
isEventItem: Boolean(payload.isEventItem),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
tagIds: cleanIds(payload.tagIds),
imagePath: cleanItemOrArtifactImagePath(payload.imagePath),
dyePreviews: cleanItemDyePreviews(payload.dyePreviews, dyeability),
insertBeforeItemId,
insertAfterItemId
};
@@ -7085,6 +7126,40 @@ function cleanDyeability(payload: Record<string, unknown>): number {
return dyeability;
}
function cleanItemDyePreviews(value: unknown, dyeability: number): ItemDyePreviewPayload[] {
if (value === undefined || value === null) {
return [];
}
if (!Array.isArray(value)) {
throw validationError('server.validation.invalidField');
}
const seen = new Set<string>();
return value.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
throw validationError('server.validation.invalidField');
}
const row = entry as Record<string, unknown>;
const partIndex = requirePositiveInteger(row.partIndex, 'server.validation.invalidField');
const colorName = cleanName(row.colorName, 'server.validation.invalidField');
const imagePath = cleanItemOrArtifactImagePath(row.imagePath);
const key = `${partIndex}:${colorName.toLowerCase()}`;
if (partIndex > dyeability || partIndex > 3 || colorName.length > 80 || imagePath === '' || seen.has(key)) {
throw validationError('server.validation.invalidField');
}
seen.add(key);
return {
partIndex,
colorName,
imagePath,
sortOrder: index * 10
};
});
}
function dyeabilityValue(value: number): string {
if (value === 3) {
return 'Triple dyeable';
@@ -7098,6 +7173,15 @@ function dyeabilityValue(value: number): string {
return 'Not dyeable';
}
function dyePreviewListValue(previews: Array<{ partIndex: number; colorName: string; imagePath?: string; image?: EntityImageValue | null }>): string {
return previews
.map((preview) => {
const imagePath = preview.imagePath ?? preview.image?.path ?? '';
return `Part ${preview.partIndex} ${preview.colorName}: ${imagePathLabel(imagePath)}`;
})
.join(', ');
}
async function orderedItemIds(client: DbClient, isEventItem: boolean): Promise<number[]> {
const rows = await client.query<{ id: number }>(
'SELECT id FROM items WHERE is_event_item = $1 ORDER BY sort_order, id',
@@ -7136,6 +7220,20 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I
}
}
async function replaceItemDyePreviews(client: DbClient, itemId: number, previews: ItemDyePreviewPayload[]): Promise<void> {
await client.query('DELETE FROM item_dye_previews WHERE item_id = $1', [itemId]);
for (const preview of previews) {
await client.query(
`
INSERT INTO item_dye_previews (item_id, part_index, color_name, image_path, sort_order)
VALUES ($1, $2, $3, $4, $5)
`,
[itemId, preview.partIndex, preview.colorName, preview.imagePath, preview.sortOrder]
);
}
}
export async function createItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanItemPayload(payload);
@@ -7185,6 +7283,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
const itemId = result.rows[0].id;
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, itemId, cleanPayload);
await replaceItemDyePreviews(client, itemId, cleanPayload.dyePreviews);
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) {
@@ -7263,6 +7362,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
}
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, id, cleanPayload);
await replaceItemDyePreviews(client, id, cleanPayload.dyePreviews);
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
await recordEditLog(client, 'items', id, 'update', userId, changes);
@@ -8207,6 +8307,7 @@ const dataToolColumns = {
'created_at',
'updated_at'
],
itemDyePreviews: ['id', 'item_id', 'part_index', 'color_name', 'image_path', 'sort_order'],
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
@@ -8517,6 +8618,7 @@ async function resetDataToolIdentities(client: DbClient): Promise<void> {
for (const tableName of [
'daily_checklist_items',
'items',
'item_dye_previews',
'recipes',
'habitats',
'wiki_edit_logs',
@@ -8673,6 +8775,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
if (scope === 'items') {
return {
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
itemDyePreviews: await tableRows(client, 'SELECT * FROM item_dye_previews ORDER BY item_id, part_index, sort_order, id'),
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
@@ -8685,6 +8788,16 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
if (scope === 'artifacts') {
return {
artifacts: await tableRows(client, 'SELECT * FROM items WHERE ancient_artifact_category_key IS NOT NULL ORDER BY sort_order, id'),
itemDyePreviews: await tableRows(
client,
`
SELECT idp.*
FROM item_dye_previews idp
JOIN items i ON i.id = idp.item_id
WHERE i.ancient_artifact_category_key IS NOT NULL
ORDER BY idp.item_id, idp.part_index, idp.sort_order, idp.id
`
),
itemFavoriteThings: await tableRows(
client,
`
@@ -8803,7 +8916,9 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
await insertRows(client, 'item_dye_previews', dataToolColumns.itemDyePreviews, dataToolTableRows(itemData, 'itemDyePreviews'));
await insertRowsIgnoreConflicts(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(artifactData, 'itemFavoriteThings'));
await insertRowsIgnoreConflicts(client, 'item_dye_previews', dataToolColumns.itemDyePreviews, dataToolTableRows(artifactData, 'itemDyePreviews'));
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));