From 86112cee6df835a7c12384f9933e53e9700993e9 Mon Sep 17 00:00:00 2001 From: luulam Date: Thu, 27 Aug 2026 14:21:25 +0700 Subject: [PATCH] feat(combos): drag-and-drop reorder with manual sortOrder - schema.js: add sortOrder REAL column to combos, bump SCHEMA_VERSION 4->5 - combosRepo: persist sortOrder, append new combos to end, add reorderCombos() atomic reorder - export/import DB: include sortOrder for round-trip - API: PUT /api/combos/order accepts { ids: string[] } and persists new order - UI: dnd-kit DndContext + SortableContext wrap flat list; drag handle (grip icon) on each ComboCard; optimistic local reorder with revert on failure Grouped-by-tag view keeps server-side order; reorder is only available in the unfiltered flat list. --- src/app/(dashboard)/dashboard/combos/page.js | 110 ++++++++++++++++--- src/app/api/combos/order/route.js | 35 ++++++ src/lib/db/index.js | 8 +- src/lib/db/repos/combosRepo.js | 36 +++++- src/lib/db/schema.js | 8 +- src/lib/localDb.js | 3 +- 6 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 src/app/api/combos/order/route.js diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 47020266..edfe2431 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -56,6 +56,11 @@ export default function CombosPage() { const { getCaps } = useModelCaps(); const [confirmState, setConfirmState] = useState(null); const { copied, copy } = useCopyToClipboard(); + // Reorder sensors: small activation distance keeps click-to-edit, drag-to-reorder. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); // Tag filter is additive (OR). Empty set = show all. "Untagged" is a // synthetic filter that matches combos whose tags array is empty. const [activeTagFilters, setActiveTagFilters] = useState(() => new Set()); @@ -219,6 +224,25 @@ export default function CombosPage() { }); }; + // Persist a manual reorder. Optimistic: re-order the local state first, + // then push the new id list to the API. A failure reverts via fetchData(). + const handleReorder = useCallback(async (oldIndex, newIndex) => { + if (oldIndex === newIndex) return; + const next = arrayMove(combos, oldIndex, newIndex); + setCombos(next); + const ids = next.map((c) => c.id); + try { + const res = await fetch("/api/combos/order", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) await fetchData(); + } catch { + await fetchData(); + } + }, [combos]); // eslint-disable-line react-hooks/exhaustive-deps + // Apply OR-of-active-tags filter. Empty set = pass through unchanged. const visibleCombos = activeTagFilters.size === 0 ? combos @@ -341,23 +365,43 @@ export default function CombosPage() { ))} ) : ( -
- {combos.map((combo) => ( - setEditingCombo(combo)} - onDelete={() => handleDelete(combo.id)} - strategy={comboStrategies[combo.name] || {}} - globalStrategy={globalComboStrategy} - onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)} - /> - ))} -
+ { + const { active, over } = e; + if (!over || active.id === over.id) return; + const oldIndex = combos.findIndex((c) => c.id === active.id); + const newIndex = combos.findIndex((c) => c.id === over.id); + if (oldIndex < 0 || newIndex < 0) return; + handleReorder(oldIndex, newIndex); + }} + > + c.id)} strategy={verticalListSortingStrategy}> +
+ {combos.map((combo) => ( + + {(handle) => ( + setEditingCombo(combo)} + onDelete={() => handleDelete(combo.id)} + strategy={comboStrategies[combo.name] || {}} + globalStrategy={globalComboStrategy} + onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)} + dragHandle={handle} + /> + )} + + ))} +
+
+
)}
+ {dragHandle}
layers
@@ -534,6 +579,35 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi ); } +// Drag-and-drop wrapper around ComboCard. The handle is a separate button +// so all card clicks (edit / delete / copy / strategy) still work — only +// dragging the grip initiates a reorder. +function SortableComboCard({ id, children }) { + const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.7 : 1, + }; + const handle = ( + + ); + return ( +
+ {typeof children === "function" ? children(handle) : children} +
+ ); +} function CapacityAdapterSection({ capacityAdapter, onChange, activeProviders, getCaps }) { return ( diff --git a/src/app/api/combos/order/route.js b/src/app/api/combos/order/route.js new file mode 100644 index 00000000..0dc16b3c --- /dev/null +++ b/src/app/api/combos/order/route.js @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { reorderCombos, getCombos } from "@/lib/localDb"; + +export const dynamic = "force-dynamic"; + +// PUT /api/combos/order - Persist a new top-to-bottom ordering. +// Body: { ids: string[] } — the new order, may be a partial slice of the list +// (entries not mentioned keep their relative position; entries not in the +// database are silently dropped by the repo). +export async function PUT(request) { + try { + const body = await request.json(); + const ids = body?.ids; + if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !id)) { + return NextResponse.json({ error: "ids must be a non-empty string array" }, { status: 400 }); + } + + // Cap the payload to guard against accidental mega-arrays. The real + // maximum is the current row count; allow a little slack for races where + // a row was just created but the client hasn't refetched. + const all = await getCombos(); + if (ids.length > all.length + 16) { + return NextResponse.json({ error: "Too many ids" }, { status: 400 }); + } + + // Dedupe to keep the repo's dense 1..N numbering correct. + const ordered = [...new Set(ids)]; + + await reorderCombos(ordered); + return NextResponse.json({ ok: true, count: ordered.length }); + } catch (error) { + console.log("Error reordering combos:", error); + return NextResponse.json({ error: "Failed to reorder combos" }, { status: 500 }); + } +} diff --git a/src/lib/db/index.js b/src/lib/db/index.js index 27a32a71..001ab502 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -35,7 +35,7 @@ export { // Combos export { getCombos, getComboById, getComboByName, - createCombo, updateCombo, deleteCombo, + createCombo, updateCombo, deleteCombo, reorderCombos, } from "./repos/combosRepo.js"; // Aliases (model + custom + mitm) @@ -79,7 +79,7 @@ export async function exportDb() { providerNodes: db.all(`SELECT * FROM providerNodes`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, type: r.type, name: r.name, createdAt: r.createdAt, updatedAt: r.updatedAt })), proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })), apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, isActive: r.isActive === 1, createdAt: r.createdAt })), - combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, kind: r.kind, models: parseJson(r.models, []), tags: parseJson(r.tags, []), createdAt: r.createdAt, updatedAt: r.updatedAt })), + combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, kind: r.kind, models: parseJson(r.models, []), tags: parseJson(r.tags, []), sortOrder: r.sortOrder ?? 0, createdAt: r.createdAt, updatedAt: r.updatedAt })), modelAliases: {}, customModels: [], mitmAlias: {}, @@ -144,8 +144,8 @@ export async function importDb(payload) { } for (const c of payload.combos || []) { db.run( - `INSERT OR REPLACE INTO combos(id, name, kind, models, tags, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, - [c.id, c.name, c.kind || null, stringifyJson(c.models || []), stringifyJson(c.tags || []), c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()] + `INSERT OR REPLACE INTO combos(id, name, kind, models, tags, sortOrder, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`, + [c.id, c.name, c.kind || null, stringifyJson(c.models || []), stringifyJson(c.tags || []), typeof c.sortOrder === "number" ? c.sortOrder : 0, c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()] ); } for (const [a, m] of Object.entries(payload.modelAliases || {})) { diff --git a/src/lib/db/repos/combosRepo.js b/src/lib/db/repos/combosRepo.js index 1157d084..8b7b812b 100644 --- a/src/lib/db/repos/combosRepo.js +++ b/src/lib/db/repos/combosRepo.js @@ -11,6 +11,7 @@ function rowToCombo(row) { models: parseJson(row.models, []), enabled: row.enabled === 1 || row.enabled === true, tags: parseJson(row.tags, []), + sortOrder: row.sortOrder ?? 0, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -18,10 +19,11 @@ function rowToCombo(row) { export async function getCombos() { const db = await getAdapter(); - const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`); + const rows = db.all(`SELECT * FROM combos ORDER BY sortOrder ASC, createdAt ASC`); return rows.map(rowToCombo); } + export async function getComboById(id) { const db = await getAdapter(); const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]); @@ -37,6 +39,10 @@ export async function getComboByName(name) { export async function createCombo(data) { const db = await getAdapter(); const now = new Date().toISOString(); + // Append to the end of the user's manual order. New combos should always + // land at the bottom unless the caller explicitly pins a position. + const maxRow = db.get(`SELECT MAX(sortOrder) AS m FROM combos`); + const nextOrder = (maxRow?.m ?? 0) + 1; const combo = { id: uuidv4(), name: data.name, @@ -44,11 +50,12 @@ export async function createCombo(data) { models: data.models || [], enabled: data.enabled !== false, tags: Array.isArray(data.tags) ? data.tags : [], + sortOrder: data.sortOrder ?? nextOrder, createdAt: now, updatedAt: now, }; db.run( - `INSERT INTO combos(id, name, kind, models, enabled, tags, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO combos(id, name, kind, models, enabled, tags, sortOrder, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ combo.id, combo.name, @@ -56,6 +63,7 @@ export async function createCombo(data) { stringifyJson(combo.models), combo.enabled !== false ? 1 : 0, stringifyJson(combo.tags), + combo.sortOrder, combo.createdAt, combo.updatedAt, ], @@ -63,6 +71,30 @@ export async function createCombo(data) { return combo; } +// Persist a manual drag-and-drop reorder. `ids` is the new top-to-bottom +// order; index N gets sortOrder = N + 1 (1-based, dense). Atomic via tx so +// the list never lands in a half-updated state if the process dies mid-write. +export async function reorderCombos(ids) { + if (!Array.isArray(ids) || ids.length === 0) return false; + const db = await getAdapter(); + const now = new Date().toISOString(); + db.transaction(() => { + // Look up the known set so unknown ids are ignored instead of throwing + // — the API layer is the trust boundary, but defensive coding here is + // cheap and avoids a surprise FK-ish failure mid-transaction. + const existing = new Set( + db.all(`SELECT id FROM combos`).map((r) => r.id), + ); + let n = 1; + for (const id of ids) { + if (typeof id !== "string" || !existing.has(id)) continue; + db.run(`UPDATE combos SET sortOrder = ?, updatedAt = ? WHERE id = ?`, [n, now, id]); + n += 1; + } + }); + return true; +} + export async function updateCombo(id, data) { const db = await getAdapter(); let result = null; diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 42157272..af502d33 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -3,7 +3,7 @@ // pre-change safety backup in migrate.js: when the stored version is lower, // one lightweight DB backup is taken before applying schema changes. Forgetting // to bump only skips that backup — it does NOT break the additive auto-sync. -export const SCHEMA_VERSION = 4; +export const SCHEMA_VERSION = 5; export const PRAGMA_SQL = ` PRAGMA journal_mode = WAL; @@ -95,10 +95,14 @@ export const TABLES = { models: "TEXT NOT NULL", enabled: "INTEGER NOT NULL DEFAULT 1", tags: "TEXT NOT NULL DEFAULT '[]'", + "sortOrder": "REAL NOT NULL DEFAULT 0", createdAt: "TEXT NOT NULL", updatedAt: "TEXT NOT NULL", }, - indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"], + indexes: [ + "CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)", + "CREATE INDEX IF NOT EXISTS idx_combo_sort_order ON combos(sortOrder)", + ], }, kv: { columns: { diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 9d1c43f3..83f12d5f 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -12,8 +12,7 @@ export { createProxyPool, updateProxyPool, deleteProxyPool, getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey, getApiKeyByKey, getCombos, getComboById, getComboByName, - createCombo, updateCombo, deleteCombo, - getModelAliases, setModelAlias, deleteModelAlias, + createCombo, updateCombo, deleteCombo, reorderCombos, getCustomModels, addCustomModel, deleteCustomModel, getMitmAlias, setMitmAliasAll, getPricing, getPricingForModel, updatePricing, resetPricing, resetAllPricing,