feat(items): support drag-and-drop reordering and contextual insert

Implement drag-and-drop sorting in the items grid
Add right-click context menu to insert new items before or after
Update backend to process insertion anchors during item creation
This commit is contained in:
2026-05-05 07:01:21 +08:00
parent a17344d216
commit 357dc061d6
8 changed files with 500 additions and 19 deletions

View File

@@ -216,6 +216,8 @@ type ItemPayload = {
acquisitionMethodIds: number[];
tagIds: number[];
imagePath: string;
insertBeforeItemId: number | null;
insertAfterItemId: number | null;
};
type AncientArtifactPayload = {
@@ -6477,9 +6479,15 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
? null
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId);
const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId);
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');
if (insertBeforeItemId !== null && insertAfterItemId !== null) {
throw validationError('server.validation.invalidField');
}
return {
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
details: cleanOptionalText(payload.details),
@@ -6495,10 +6503,28 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
isEventItem: Boolean(payload.isEventItem),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
tagIds: cleanIds(payload.tagIds),
imagePath: cleanUploadImagePath(payload.imagePath, 'items')
imagePath: cleanUploadImagePath(payload.imagePath, 'items'),
insertBeforeItemId,
insertAfterItemId
};
}
function cleanOptionalPositiveInteger(value: unknown): number | null {
if (value === null || value === '' || value === undefined) {
return null;
}
return requirePositiveInteger(value, 'server.validation.invalidField');
}
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',
[isEventItem]
);
return rows.rows.map((row) => row.id);
}
async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise<void> {
if (!noRecipe) {
return;
@@ -6573,6 +6599,28 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, itemId, cleanPayload);
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) {
const targetId = cleanPayload.insertBeforeItemId ?? cleanPayload.insertAfterItemId;
if (targetId === null) {
throw validationError('server.validation.invalidField');
}
const orderedIds = await orderedItemIds(client, cleanPayload.isEventItem);
const targetIndex = orderedIds.indexOf(targetId);
if (targetIndex < 0) {
throw validationError('server.validation.recordMissing');
}
const insertedIndex = orderedIds.indexOf(itemId);
if (insertedIndex >= 0) {
orderedIds.splice(insertedIndex, 1);
}
orderedIds.splice(targetIndex + (cleanPayload.insertAfterItemId !== null ? 1 : 0), 0, itemId);
await reorderTableRows(client, 'items', 'items', orderedIds, userId);
}
await recordEditLog(client, 'items', itemId, 'create', userId);
return itemId;
});

View File

@@ -1796,9 +1796,21 @@ app.get('/api/items/:id', async (request, reply) => {
app.post('/api/items', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.create', 'wikiWrite');
return user
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
if (!user) {
return undefined;
}
const payload = request.body as Record<string, unknown>;
const hasInsertAnchor =
(payload.insertBeforeItemId !== undefined && payload.insertBeforeItemId !== null && payload.insertBeforeItemId !== '') ||
(payload.insertAfterItemId !== undefined && payload.insertAfterItemId !== null && payload.insertAfterItemId !== '');
if (hasInsertAnchor && !userHasPermission(user, 'items.order')) {
reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') });
return undefined;
}
return reply.code(201).send(await createItem(payload, user.id, requestLocale(request)));
});
app.put('/api/items/:id', async (request, reply) => {