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:
@@ -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 '';
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user