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.
This commit is contained in:
2026-08-27 14:21:25 +07:00
parent 5c6048759c
commit 86112cee6d
6 changed files with 172 additions and 28 deletions

View File

@@ -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() {
))}
</div>
) : (
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<ComboCard
key={combo.id}
combo={combo}
getCaps={getCaps}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
strategy={comboStrategies[combo.name] || {}}
globalStrategy={globalComboStrategy}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
/>
))}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(e) => {
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);
}}
>
<SortableContext items={combos.map((c) => c.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<SortableComboCard key={combo.id} id={combo.id}>
{(handle) => (
<ComboCard
combo={combo}
getCaps={getCaps}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
strategy={comboStrategies[combo.name] || {}}
globalStrategy={globalComboStrategy}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
dragHandle={handle}
/>
)}
</SortableComboCard>
))}
</div>
</SortableContext>
</DndContext>
)}
<CapacityAdapterSection
capacityAdapter={capacityAdapter}
@@ -407,7 +451,7 @@ const STRATEGY_OPTIONS = [
{ value: "fusion", label: "Fusion — panel + judge" },
];
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, globalStrategy = "fallback", onSetStrategy }) {
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, globalStrategy = "fallback", onSetStrategy, dragHandle = null }) {
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
// Show the effective strategy: per-combo override first, then the global
// default (combos without an entry fall through to settings.comboStrategy).
@@ -419,6 +463,7 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
<Card padding="sm" className="group">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
{dragHandle}
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
</div>
@@ -534,6 +579,35 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
</Card>
);
}
// 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 = (
<button
ref={setActivatorNodeRef}
type="button"
aria-label="Drag to reorder"
title="Drag to reorder"
className="hidden sm:flex size-7 cursor-grab items-center justify-center rounded text-text-muted hover:bg-black/5 hover:text-text-main active:cursor-grabbing dark:hover:bg-white/5 touch-none"
{...attributes}
{...listeners}
>
<span className="material-symbols-outlined text-[18px]">drag_indicator</span>
</button>
);
return (
<div ref={setNodeRef} style={style} className="relative">
{typeof children === "function" ? children(handle) : children}
</div>
);
}
function CapacityAdapterSection({ capacityAdapter, onChange, activeProviders, getCaps }) {
return (

View File

@@ -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 });
}
}

View File

@@ -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 || {})) {

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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,