+ {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,