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:
@@ -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 (
|
||||
|
||||
35
src/app/api/combos/order/route.js
Normal file
35
src/app/api/combos/order/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 || {})) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user