PageSourceSearch

https://ridedna.ai/assets/FrameSpecs-B22WsT8e.js

js ridedna.ai collected 2026-10-05 14:39:18 UTC 40,658 bytes, 240 lines download raw bytes

1import { c as ee, d as K, r as u, e as q, J as S, y as C, K as k, j as e, B as x, ag as G, C as A, a4 as I, I as E, a$ as X, b0 as Z, by as le, aX as Ee, A as Pe, bk as oe, a5 as L, Q, a9 as R, cM as Me, T as ce, bv as me, a7 as ue, aG as pe, M as se, s as Fe, t as $e, v as Te, w as Ae, a1 as Ie, P as Le } from "./index-B9miRpwC.js";
2import { C as xe, a as he, b as ge } from "./collapsible-lrkyHkBq.js";
3import { A as Oe, a as ze, b as qe, c as Ke, d as Qe, e as Re, f as Ve, g as Be } from "./alert-dialog-Cv39x3F5.js";
4import { L as Ue } from "./layout-grid-CED3Fv53.js";
5import { P as He } from "./package-CQTVcVCl.js";
6import { P as V } from "./plus-BCaMPs2R.js";
7import { U as fe } from "./upload-u0DU9Zyt.js";
8import { G as te } from "./grip-vertical-2GXaSeBG.js";
9import { P as ae } from "./pencil-CWIGhVLH.js";
10/**
11* @license lucide-react v0.453.0 - ISC
12*
13* This source code is licensed under the ISC license.
14* See the LICENSE file in the root directory of this source tree.
15*/
16const Ge = ee("ClipboardPaste", [["path", { d: "M15 2H9a1 1 0 0 0-1 1v2c0 .6.4 1 1 1h6c.6 0 1-.4 1-1V3c0-.6-.4-1-1-1Z", key: "1pp7kr" }], ["path", { d: "M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2M16 4h2a2 2 0 0 1 2 2v2M11 14h10", key: "2ik1ml" }], ["path", { d: "m17 10 4 4-4 4", key: "vp2hj1" }]]);
17/**
18* @license lucide-react v0.453.0 - ISC
19*
20* This source code is licensed under the ISC license.
21* See the LICENSE file in the root directory of this source tree.
22*/
23const Xe = ee("FileSpreadsheet", [["path", { d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z", key: "1rqfz7" }], ["path", { d: "M14 2v4a2 2 0 0 0 2 2h4", key: "tnqrlb" }], ["path", { d: "M8 13h2", key: "yr2amv" }], ["path", { d: "M14 13h2", key: "un5t4a" }], ["path", { d: "M8 17h2", key: "2yhykz" }], ["path", { d: "M14 17h2", key: "10kma7" }]]);
24/**
25* @license lucide-react v0.453.0 - ISC
26*
27* This source code is licensed under the ISC license.
28* See the LICENSE file in the root directory of this source tree.
29*/
30const Ze = ee("Merge", [["path", { d: "m8 6 4-4 4 4", key: "ybng9g" }], ["path", { d: "M12 2v10.3a4 4 0 0 1-1.172 2.872L4 22", key: "1hyw0i" }], ["path", { d: "m20 22-5-5", key: "1m27yz" }]]);
31function Je({ entry: s, entries: p, modelNameMap: h, onUpdate: j, onDelete: v, onReorder: g, rowIndex: c }) {
32  const [n, l] = u.useState(!1), [t, d] = u.useState(s.subLabel || ""), [y, m] = u.useState(s.value), [r, i] = u.useState(s.note || ""), M = () => {
33    j(s.id, { subLabel: t.trim() || null, value: y.trim(), note: r.trim() || null }), l(!1);
34  }, D = (f) => {
35    f.dataTransfer.setData("entry-id", String(s.id)), f.dataTransfer.effectAllowed = "move";
36  }, N = (f) => {
37    f.preventDefault(), f.dataTransfer.dropEffect = "move";
38  }, o = (f) => {
39    f.preventDefault();
40    const P = parseInt(f.dataTransfer.getData("entry-id"));
41    if (!P || P === s.id) return;
42    const $ = p.map((J) => J.id), O = $.indexOf(P), B = $.indexOf(s.id);
43    if (O === -1 || B === -1) return;
44    const z = [...$];
45    z.splice(O, 1), z.splice(B, 0, P), g(z);
46  };
47  if (n) return e.jsxs("div", { className: "flex gap-2 items-start py-1 border rounded-md p-2 bg-muted/30", children: [e.jsxs("div", { className: "flex-1 grid grid-cols-3 gap-2", children: [e.jsx(E, { placeholder: "Sub-label (optional)", value: t, onChange: (f) => d(f.target.value), "data-testid": "input-entry-sublabel" }), e.jsx(E, { placeholder: "Value", value: y, onChange: (f) => m(f.target.value), "data-testid": "input-entry-value" }), e.jsx(E, { placeholder: "Note (optional)", value: r, onChange: (f) => i(f.target.value), "data-testid": "input-entry-note" })] }), e.jsx(x, { size: "icon", variant: "ghost", onClick: M, "data-testid": "button-save-entry", children: e.jsx(Q, { className: "w-4 h-4" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => l(!1), "data-testid": "button-cancel-entry", children: e.jsx(R, { className: "w-4 h-4" }) })] });
48  const b = (s.assignedModelIds ?? []).map((f) => h.get(f) ?? f).sort(), w = c % 2 === 0;
49  return e.jsxs("div", { draggable: !0, onDragStart: D, onDragOver: N, onDrop: o, className: `flex items-center gap-2 py-1.5 px-2 rounded-md group select-none transition-colors ${w ? "bg-muted/30" : ""} hover:bg-accent/40`, "data-testid": `entry-row-${s.id}`, children: [e.jsx(te, { className: "w-4 h-4 text-muted-foreground
49/40 flex-shrink-0 cursor-grab active:cursor-grabbing" }), e.jsx("div", { className: "flex-1 min-w-0", children: e.jsxs("div", { className: "flex items-baseline gap-2 flex-wrap", children: [s.subLabel && e.jsxs("span", { className: "text-sm text-muted-foreground flex-shrink-0", children: [s.subLabel, ":"] }), e.jsx("span", { className: "text-sm font-medium", children: s.value }), s.note && e.jsxs("span", { className: "text-xs text-muted-foreground/70 italic", children: ["(", s.note, ")"] }), b.length > 0 && e.jsx("div", { className: "flex flex-wrap gap-1 ml-1", children: b.map((f) => e.jsx(L, { variant: "secondary", className: "text-xs py-0 h-5 no-default-active-elevate", children: f }, f)) })] }) }), e.jsxs("div", { className: "flex gap-0.5 flex-shrink-0", children: [e.jsx(x, { size: "icon", variant: "ghost", onClick: () => l(!0), "data-testid": `button-edit-entry-${s.id}`, title: "Edit this entry", children: e.jsx(ae, { className: "w-3 h-3" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => v(s.id), "data-testid": `button-delete-entry-${s.id}`, title: "Delete this entry", children: e.jsx(ce, { className: "w-3 h-3" }) })] })] });
50}
51function We({ specType: s, allTypes: p, modelNameMap: h, onDeleted: j, onUpdated: v, onReorderTypes: g }) {
52  const { toast: c } = K(), [n, l] = u.useState(!1), [t, d] = u.useState(!1), [y, m] = u.useState(s.name), [r, i] = u.useState(!1), [M, D] = u.useState(""), [N, o] = u.useState(""), [b, w] = u.useState(""), [f, P] = u.useState(""), [$, O] = u.useState(!1), [B, z] = u.useState(!1), [J, W] = u.useState(!1), { data: U = [], isLoading: je } = q({ queryKey: ["/api/admin/frame-specs/types", s.id, "entries"], queryFn: async () => {
53    const a = await fetch(`/api/admin/frame-specs/types/${s.id}/entries`);
54    if (!a.ok) throw new Error("Failed to load entries");
55    return a.json();
56  }, enabled: n }), ne = S({ mutationFn: async (a) => (await k("PATCH", `/api/admin/frame-specs/types/${s.id}`, a)).json(), onSuccess: (a) => {
57    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), v(s.id, a.name), d(!1), c({ title: "Category updated" });
58  } }), ie = S({ mutationFn: async (a) => (await k("POST", "/api/admin/frame-specs/entries", a)).json(), onSuccess: () => {
59    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types", s.id, "entries"] }), D(""), o(""), w(""), i(!1), c({ title: "Entry added" });
60  } }), ve = S({ mutationFn: async ({ id: a, data: T }) => (await k("PATCH", `/api/admin/frame-specs/entries/${a}`, T)).json(), onSuccess: () => {
61    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types", s.id, "entries"] }), c({ title: "Entry updated" });
62  } }), ye = S({ mutationFn: async (a) => k("DELETE", `/api/admin/frame-specs/entries/${a}`), onSuccess: () => {
63    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types", s.id, "entries"] }), c({ title: "Entry deleted" });
64  } }), Ne = S({ mutationFn: async (a) => k("POST", "/api/admin/frame-specs/entries/reorder", { specTypeId: s.id, ids: a }), onSuccess: () => {
65    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types", s.id, "entries"] });
66  }, onError: () => c({ title: "Failed to reorder entries", variant: "destructive" }) }), _ = S({ mutationFn: async (a) => (await k("POST", `/api/admin/frame-specs/types/${s.id}/paste`, { text: a })).json(), onSuccess: () => {
67    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types", s.id, "entries"] }), P(""), O(!1), c({ title: "Entries pasted successfully" });
68  } }), re = S({ mutationFn: async () => (await k("POST", `/api/admin/frame-specs/types/${s.id}/assign-to-all`, {})).json(), onSuccess: () => {
69    c({ title: "Assigned to all models" });
70  } }), be = S({ mutationFn: async () => k("DELETE", `/api/admin/frame-specs/types/${s.id}`), onSuccess: () => {
71    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), j(s.id), c({ title: "Category deleted" });
72  } }), we = (a) => {
73    a.dataTransfer.setData("type-id", String(s.id)), a.dataTransfer.effectAllowed = "move";
74  }, Ce = (a) => {
75    a.dataTransfer.types.includes("type-id") && (a.preventDefault(), a.dataTransfer.dropEffect = "move", W(!0));
76  }, Se = () => W(!1), ke = (a) => {
77    a.preventDefault(), W(!1);
78    const T = parseInt(a.dataTransfer.getData("type-id"));
79    if (!T || T === s.id) return;
80    const F = p.map((De) => De.id), H = F.indexOf(T), de = F.indexOf(s.id);
81    if (H === -1 || de === -1) return;
82    const Y = [...F];
83    Y.splice(H, 1), Y.splice(de, 0, T), g(Y);
84  };
85  return e.jsxs(A, { "data-testid": `spec-type-section-${s.id}`, className: J ? "ring-2 ring-primary" : "", onDragOver: Ce, onDragLeave: Se, onDrop: ke, children: [e.jsxs(xe, { open: n, onOpenChange: l, children: [e.jsx(he, { asChild: !0, children: e.jsxs(X, { className: "cursor-pointer hover-elevate flex flex-row items-center justify-between gap-2 py-3", children: [e.jsxs("div", { className: "flex items-center gap-3 flex-1 min-w-0", children: [e.jsx("div", { draggable: !0, onDragStart: we, onClick: (a) => a.stopPropagation(), className: "cursor-grab active:cursor-grabbing flex-shrink-0", title: "Drag to reorder 
85category", "data-testid": `grip-type-${s.id}`, children: e.jsx(te, { className: "w-4 h-4 text-muted-foreground/50" }) }), t ? e.jsxs("div", { className: "flex items-center gap-2 flex-1", onClick: (a) => a.stopPropagation(), children: [e.jsx(E, { value: y, onChange: (a) => m(a.target.value), className: "h-8 text-sm", "data-testid": "input-type-name", autoFocus: !0, onKeyDown: (a) => {
86    a.key === "Enter" && ne.mutate({ name: y });
87  } }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => ne.mutate({ name: y }), "data-testid": "button-save-type-name", children: e.jsx(Q, { className: "w-4 h-4" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => {
88    m(s.name), d(!1);
89  }, children: e.jsx(R, { className: "w-4 h-4" }) })] }) : e.jsx(Z, { className: "text-base", children: s.name }), e.jsxs(L, { variant: "outline", className: "text-xs flex-shrink-0 no-default-active-elevate", children: [n ? U.length : s.entryCount ?? 0, " entries"] })] }), e.jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", onClick: (a) => a.stopPropagation(), children: [e.jsx(x, { size: "icon", variant: "ghost", onClick: () => d(!t), "data-testid": `button-edit-type-${s.id}`, children: e.jsx(ae, { className: "w-3 h-3" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => re.mutate(), disabled: re.isPending, title: "Assign all entries to every model", "data-testid": `button-assign-all-${s.id}`, children: e.jsx(Me, { className: "w-3 h-3" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => z(!0), "data-testid": `button-delete-type-${s.id}`, children: e.jsx(ce, { className: "w-3 h-3" }) }), n ? e.jsx(me, { className: "w-4 h-4 text-muted-foreground" }) : e.jsx(ue, { className: "w-4 h-4 text-muted-foreground" })] })] }) }), e.jsx(ge, { children: e.jsxs(I, { className: "pt-0 space-y-3", children: [je ? e.jsx("p", { className: "text-sm text-muted-foreground", children: "Loading entr
89ies..." }) : e.jsx("div", { className: "space-y-0.5", children: U.map((a, T) => e.jsx(Je, { entry: a, entries: U, modelNameMap: h, onUpdate: (F, H) => ve.mutate({ id: F, data: H }), onDelete: (F) => ye.mutate(F), onReorder: (F) => Ne.mutate(F), rowIndex: T }, a.id)) }), r && e.jsxs("div", { className: "flex gap-2 items-center border rounded-md p-2 bg-muted/20", children: [e.jsxs("div", { className: "flex-1 grid grid-cols-3 gap-2", children: [e.jsx(E, { placeholder: "Sub-label", value: M, onChange: (a) => D(a.target.value), "data-testid": "input-new-sublabel" }), e.jsx(E, { placeholder: "Value *", value: N, onChange: (a) => o(a.target.value), "data-testid": "input-new-value" }), e.jsx(E, { placeholder: "Note", value: b, onChange: (a) => w(a.target.value), "data-testid": "input-new-note" })] }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => ie.mutate({ specTypeId: s.id, subLabel: M.trim() || null, value: N.trim(), note: b.trim() || null, displayOrder: U.length }), disabled: !N.trim() || ie.isPending, "data-testid": "button-confirm-new-entry", children: e.jsx(Q, { className: "w-4 h-4" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => i(!1), children: e.jsx(R, { className: "w-4 h-4" }) })] }), $ && e.jsxs("div", { className: "space-y-2", children: [e.jsx(oe, { placeholder: `Paste entries \u2014 one per line:
90\u2022 SubLabel: Value (optional note)
91\u2022 Value only
92\u2022 SubLabel: Value (note)`, value: f, onChange: (a) => P(a.target.value), rows: 5, "data-testid": "textarea-per-category-paste" }), e.jsxs("div", { className: "flex gap-2", children: [e.jsx(x, { size: "sm", onClick: () => _.mutate(f), disabled: !f.trim() || _.isPending, "data-testid": "button-paste-confirm", children: _.isPending ? "Adding..." : "Add Entries" }), e.jsx(x, { size: "sm", variant: "outline", onClick: () => {
93    O(!1), P("");
94  }, children: "Cancel" })] })] }), e.jsxs("div", { className: "flex gap-2 pt-1", children: [e.jsxs(x, { size: "sm", variant: "outline", onClick: () => i(!0), "data-testid": `button-add-entry-${s.id}`, children: [e.jsx(V, { className: "w-3 h-3 mr-1" }), "Add Entry"] }), e.jsxs(x, { size: "sm", variant: "outline", onClick: () => O(!$), "data-testid": `button-paste-entries-${s.id}`, children: [e.jsx(Ge, { className: "w-3 h-3 mr-1" }), "Paste Entries"] })] })] }) })] }), e.jsx(Oe, { open: B, onOpenChange: z, children: e.jsxs(ze, { children: [e.jsxs(qe, { children: [e.jsx(Ke, { children: "Delete Category" }), e.jsxs(Qe, { children: ['Delete "', s.name, '" and all its entries? This cannot be undone.'] })] }), e.jsxs(Re, { children: [e.jsx(Ve, { children: "Cancel" }), e.jsx(Be, { className: "bg-destructive text-destructive-foreground", onClick: () => be.mutate(), children: "Delete" })] })] }) })] });
95}
96function _e() {
97  const { toast: s } = K(), p = u.useRef(null), [h, j] = u.useState(null), [v, g] = u.useState(!1), c = async (n) => {
98    g(!0), j(null);
99    try {
100      const l = new FormData();
101      l.append("file", n);
102      const t = await fetch("/api/admin/frame-specs/xlsx-import", { method: "POST", credentials: "include", body: l }), d = await t.json();
103      if (!t.ok) throw new Error(d.error ?? "Import failed");
104      j(d), C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), s({ title: "Import complete", description: `${d.entriesCreated} created, ${d.entriesUpdated} updated across ${d.categoriesCreated + d.categoriesFound} categories.` });
105    } catch (l) {
106      s({ title: "Import failed", description: l?.message ?? "Unknown error", variant: "destructive" });
107    } finally {
108      g(!1), p.current && (p.current.value = "");
109    }
110  };
111  return e.jsxs(A, { children: [e.jsxs(X, { className: "pb-3", children: [e.jsxs(Z, { className: "text-base flex items-center gap-2", children: [e.jsx(Xe, { className: "w-4 h-4" }), "Import from Spreadsheet"] }), e.jsx(le, { children: "Upload an .xlsx file in the Airborne Frame Specs format \u2014 category header rows, spec label rows with per-model columns. Re-importing updates existing values; new rows are added." })] }), e.jsxs(I, { className: "space-y-4", children: [e.jsx("input", { ref: p, type: "file", accept: ".xlsx,.xls", className: "hidden", onChange: (n) => {
112    var t;
113    const l = (t = n.target.files) == null ? void 0 : t[0];
114    l && c(l);
115  } }), e.jsxs(x, { variant: "outline", onClick: () => {
116    var n;
117    return (n = p.current) == null ? void 0 : n.click();
118  }, disabled: v, "data-testid": "button-xlsx-import", children: [v ? e.jsx(G, { className: "w-4 h-4 mr-2 animate-spin" }) : e.jsx(fe, { className: "w-4 h-4 mr-2" }), v ? "Importing\u2026" : "Select .xlsx file"] }), h && e.jsxs("div", { className: "rounded-md border border-border p-3 space-y-2 bg-muted/30", children: [e.jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [e.jsx(Ee, { className: "w-4 h-4 text-emerald-500" }), "Import complete"] }), e.jsxs("div", { className: "grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted-foreground", children: [e.jsx("span", { children: "Categories created" }), e.jsx("span", { className: "font-mono", children: h.categoriesCreated }), e.jsx("span", { children: "Categories found" }), e.jsx("span", { className: "font-mono", children: h.categoriesFound }), e.jsx("span", { children: "New spec entries" }), e.jsx("span", { className: "font-mono", children: h.entriesCreated }), e.jsx("span", { children: "Model assignments added" }), e.jsx("span", { className: "font-mono", children: h.assignmentsCreated }), e.jsx("span", { children: "Assignments already existed" }), e.jsx("span", { className: "font-mono", children: h.assignmentsSkipped })] }), h.unmappedModels.length > 0 && e.jsxs("div", { className: "flex items-start gap-2 text-xs text-amber-600 dar
118k:text-amber-400 pt-1", children: [e.jsx(Pe, { className: "w-3.5 h-3.5 shrink-0 mt-0.5" }), e.jsxs("span", { children: ["Skipped (no matching model in DB):", " ", h.unmappedModels.join(", ")] })] })] })] })] });
119}
120function Ye() {
121  const { toast: s } = K(), [p, h] = u.useState(""), [j, v] = u.useState(null), [g, c] = u.useState({}), n = S({ mutationFn: async (t) => (await k("POST", "/api/admin/frame-specs/bulk-import/preview", { text: t })).json(), onSuccess: (t) => {
122    v(t.preview);
123    const d = {};
124    t.preview.forEach((y, m) => {
125      var r;
126      d[m] = { useExistingId: (r = y.matchedType) == null ? void 0 : r.id, isNew: y.isNew };
127    }), c(d);
128  }, onError: () => s({ title: "Failed to parse", variant: "destructive" }) }), l = S({ mutationFn: async () => {
129    if (!j) return;
130    const t = j.map((y, m) => {
131      var i;
132      const r = g[m];
133      return { name: y.inputName, existingTypeId: r?.useExistingId ?? ((i = y.matchedType) == null ? void 0 : i.id), entries: y.entries };
134    });
135    return (await k("POST", "/api/admin/frame-specs/bulk-import/confirm", { categories: t })).json();
136  }, onSuccess: () => {
137    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), h(""), v(null), s({ title: "Bulk import complete" });
138  }, onError: () => s({ title: "Import failed", variant: "destructive" }) });
139  return e.jsxs(A, { children: [e.jsxs(X, { children: [e.jsxs(Z, { className: "flex items-center gap-2", children: [e.jsx(fe, { className: "w-4 h-4" }), "Bulk Import"] }), e.jsx(le, { children: "Paste a doc (blank-line separated blocks, first line = category) or TSV (category, sub-label, value, note). Format is auto-detected." })] }), e.jsxs(I, { className: "space-y-4", children: [e.jsx(oe, { placeholder: `Bottom Bracket
140Thread Standard: BSA 68mm (English threaded)
141Spacer: 2.5mm (drive side)
142
143Thru-Axle
144Front: 15x110mm Boost
145Rear: 12x148mm Boost`, value: p, onChange: (t) => h(t.target.value), rows: 8, "data-testid": "textarea-bulk-import" }), e.jsxs("div", { className: "flex gap-2", children: [e.jsx(x, { onClick: () => n.mutate(p), disabled: !p.trim() || n.isPending, "data-testid": "button-preview-bulk", children: n.isPending ? "Parsing..." : "Preview Import" }), j && e.jsx(x, { variant: "outline", onClick: () => {
146    v(null);
147  }, children: "Clear Preview" })] }), j && j.length > 0 && e.jsxs("div", { className: "space-y-3", children: [e.jsxs("p", { className: "text-sm font-medium text-muted-foreground", children: ["Preview \u2014 ", j.length, " categories, ", j.reduce((t, d) => t + d.entries.length, 0), " entries total"] }), j.map((t, d) => {
148    var y, m;
149    return e.jsxs("div", { className: "border rounded-md p-3 space-y-2", "data-testid": `preview-category-${d}`, children: [e.jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [e.jsx("span", { className: "font-medium text-sm", children: t.inputName }), t.matchedType && e.jsxs(L, { variant: "outline", className: "text-xs text-green-600 border-green-500", children: ["Matched: ", t.matchedType.name] }), t.isNew && e.jsx(L, { variant: "outline", className: "text-xs text-blue-600 border-blue-500", children: "New category" }), t.suggestion && t.isNew && !((y = g[d]) != null && y.useExistingId) && e.jsxs(x, { size: "sm", variant: "ghost", className: "text-xs h-6 px-2", onClick: () => c((r) => ({ ...r, [d]: { useExistingId: t.suggestion.id, isNew: !1 } })), children: ["Use existing: ", t.suggestion.name] }), ((m = g[d]) == null ? void 0 : m.useExistingId) && t.isNew && e.jsxs(L, { variant: "outline", className: "text-xs", children: ["Will add to existing category", e.jsx("button", { className: "ml-1 underline", onClick: () => c((r) => ({ ...r, [d]: { isNew: !0 } })), children: "undo" })] })] }), e.jsxs("div", { className: "text-xs text-muted-foreground", children: [t.entries.slice(0, 5).map((r, i) => e.jsxs("div", { children: [r.subLabel ? `${r.subLabel}: ` : "", r.value, r.note ? ` (${r.note})` : ""] }, i)), t.entries.length > 5 && e.jsxs("div", { children: ["... and ", t.entries.length - 5, " more"] })] })] }, d);
150  }), e.jsx(x, { onClick: () => l.mutate(), disabled: l.isPending, "data-testid": "button-confirm-bulk", children: l.isPending ? "Importing..." : `Import ${j.length} categories` })] })] })] });
151}
152function es({ open: s, onClose: p, modelId: h, modelName: j, assignedEntryIds: v }) {
153  const { toast: g } = K(), [c, n] = u.useState(""), [l, t] = u.useState(/* @__PURE__ */ new Set()), [d, y] = u.useState(null), { data: m = [] } = q({ queryKey: ["/api/admin/frame-specs/types"], queryFn: async () => {
154    const o = await fetch("/api/admin/frame-specs/types");
155    if (!o.ok) throw new Error("Failed");
156    return o.json();
157  }, enabled: s }), { data: r = [] } = q({ queryKey: ["/api/admin/frame-specs/types", d, "entries"], queryFn: async () => {
158    if (!d) return [];
159    const o = await fetch(`/api/admin/frame-specs/types/${d}/entries`);
160    if (!o.ok) throw new Error("Failed");
161    return o.json();
162  }, enabled: s && d !== null }), i = S({ mutationFn: async (o) => k("POST", `/api/admin/models/${h}/frame-specs/assign`, { entryIds: o }), onSuccess: () => {
163    C.invalidateQueries({ queryKey: ["/api/admin/models", h, "frame-specs"] }), g({ title: `Added ${l.size} spec${l.size !== 1 ? "s" : ""} to ${j}` }), t(/* @__PURE__ */ new Set()), p();
164  }, onError: () => g({ title: "Failed to add specs", variant: "destructive" }) }), M = (o) => {
165    t((b) => {
166      const w = new Set(b);
167      return w.has(o) ? w.delete(o) : w.add(o), w;
168    });
169  }, D = c.toLowerCase(), N = r.filter((o) => D ? o.value.toLowerCase().includes(D) || (o.subLabel ?? "").toLowerCase().includes(D) || (o.note ?? "").toLowerCase().includes(D) : !0);
170  return e.jsx(Fe, { open: s, onOpenChange: (o) => {
171    o || (p(), t(/* @__PURE__ */ new Set()), n(""));
172  }, children: e.jsxs($e, { className: "max-w-lg", children: [e.jsx(Te, { children: e.jsxs(Ae, { children: ["Add Specs to ", j] }) }), e.jsxs("div", { className: "space-y-3", children: [e.jsxs("div", { className: "relative", children: [e.jsx(pe, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" }), e.jsx(E, { placeholder: "Filter entries\u2026", value: c, onChange: (o) => n(o.target.value), className: "pl-8", "data-testid": "input-add-spec-search" })] }), e.jsx(se, { className: "h-72 border rounded-md", children: e.jsx("div", { className: "p-2 space-y-1", children: m.map((o) => e.jsxs("div", { children: [e.jsxs("button", { type: "button", onClick: () => y((b) => b === o.id ? null : o.id), className: "w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover-elevate text-left", "data-testid": `picker-type-${o.id}`, children: [e.jsx(Ie, { className: `w-3.5 h-3.5 text-muted-foreground transition-transform ${d === o.id ? "rotate-90" : ""}` }), e.jsx("span", { className: "text-sm font-medium flex-1", children: o.name }), e.jsx(L, { variant: "outline", className: "text-xs no-default-active-elevate", children: o.entryCount ?? 0 })] }), d === o.id && e.jsx("div", { className: "ml-6 mt-0.5 space-y-0.5", children: N.length === 0 ? e.jsx("p", { className: "text-xs text-muted-foreground px-2 py-1", children: "N
172o entries" }) : N.map((b) => {
173    const w = v.has(b.id), f = l.has(b.id);
174    return e.jsxs("button", { type: "button", disabled: w, onClick: () => !w && M(b.id), className: `w-full flex items-start gap-2 px-2 py-1 rounded-md text-left text-sm transition-colors ${w ? "opacity-40 cursor-not-allowed" : f ? "bg-primary/10" : "hover-elevate"}`, "data-testid": `picker-entry-${b.id}`, children: [e.jsx("div", { className: `w-3.5 h-3.5 mt-0.5 rounded border flex-shrink-0 flex items-center justify-center ${w ? "bg-muted border-border" : f ? "bg-primary border-primary" : "border-border"}`, children: (w || f) && e.jsx(Q, { className: "w-2.5 h-2.5 text-primary-foreground" }) }), e.jsxs("span", { className: "flex-1 min-w-0", children: [b.subLabel && e.jsxs("span", { className: "text-muted-foreground", children: [b.subLabel, ": "] }), b.value, b.note && e.jsxs("span", { className: "text-muted-foreground/70 text-xs italic", children: [" (", b.note, ")"] })] }), w && e.jsx(L, { variant: "secondary", className: "text-xs py-0 h-4 no-default-active-elevate flex-shrink-0", children: "Assigned" })] }, b.id);
175  }) })] }, o.id)) }) }), e.jsxs("div", { className: "flex items-center justify-between pt-1", children: [e.jsx("span", { className: "text-sm text-muted-foreground", children: l.size > 0 ? `${l.size} selected` : "Select entries to add" }), e.jsxs("div", { className: "flex gap-2", children: [e.jsx(x, { variant: "outline", size: "sm", onClick: () => {
176    p(), t(/* @__PURE__ */ new Set()), n("");
177  }, children: "Cancel" }), e.jsxs(x, { size: "sm", disabled: l.size === 0 || i.isPending, onClick: () => i.mutate(Array.from(l)), "data-testid": "button-confirm-add-specs", children: [i.isPending ? e.jsx(G, { className: "w-3 h-3 mr-1 animate-spin" }) : e.jsx(V, { className: "w-3 h-3 mr-1" }), "Add ", l.size > 0 ? l.size : "", " Spec", l.size !== 1 ? "s" : ""] })] })] })] })] }) });
178}
179function ss({ entry: s, categoryEntries: p, onReorder: h, onRemove: j, onUpdate: v }) {
180  const [g, c] = u.useState(!1), [n, l] = u.useState(s.subLabel || ""), [t, d] = u.useState(s.value), [y, m] = u.useState(s.note || ""), r = () => {
181    v(s.id, { subLabel: n.trim() || null, value: t.trim(), note: y.trim() || null }), c(!1);
182  }, i = (N) => {
183    N.dataTransfer.setData("model-entry-id", String(s.id)), N.dataTransfer.effectAllowed = "move";
184  }, M = (N) => {
185    N.dataTransfer.types.includes("model-entry-id") && (N.preventDefault(), N.dataTransfer.dropEffect = "move");
186  }, D = (N) => {
187    N.preventDefault();
188    const o = parseInt(N.dataTransfer.getData("model-entry-id"));
189    if (!o || o === s.id) return;
190    const b = p.map(($) => $.id), w = b.indexOf(o), f = b.indexOf(s.id);
191    if (w === -1 || f === -1) return;
192    const P = [...b];
193    P.splice(w, 1), P.splice(f, 0, o), h(P);
194  };
195  return g ? e.jsxs("div", { className: "flex gap-2 items-start py-1 border rounded-md p-2 bg-muted/30", children: [e.jsx("div", { className: "flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 mt-1 flex-shrink-0", title: "Editing this entry changes it for all models", children: e.jsx(Le, { className: "w-3 h-3" }) }), e.jsxs("div", { className: "flex-1 grid grid-cols-3 gap-2", children: [e.jsx(E, { placeholder: "Sub-label", value: n, onChange: (N) => l(N.target.value), "data-testid": "input-model-entry-sublabel" }), e.jsx(E, { placeholder: "Value", value: t, onChange: (N) => d(N.target.value), "data-testid": "input-model-entry-value" }), e.jsx(E, { placeholder: "Note", value: y, onChange: (N) => m(N.target.value), "data-testid": "input-model-entry-note" })] }), e.jsx(x, { size: "icon", variant: "ghost", onClick: r, "data-testid": "button-save-model-entry", children: e.jsx(Q, { className: "w-4 h-4" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => c(!1), children: e.jsx(R, { className: "w-4 h-4" }) })] }) : e.jsxs("div", { draggable: !0, onDragStart: i, onDragOver: M, onDrop: D, className: "flex items-start gap-2 py-1.5 group select-none", "data-testid": `model-entry-row-${s.id}`, children: [e.jsx(te, { className: "w-4 h-4 text-muted-foreground/50 flex-shrink-0 cursor-grab active:cursor-grabbing mt-0.5" }), e.jsxs("div", { className: "flex-1 min-w-0 flex items-baseline gap-2 flex-wrap", children: [s.subLabel && e.jsxs("span", { className: "text-sm text-muted-foreground flex-shrink-0", children: [s.subLabel, ":"] }), e.jsx("span", { className: "text-sm font-medium", children: s.value }), s.note && e.jsxs("span", { className: "text-xs text-muted-foreground
195/70 italic", children: ["(", s.note, ")"] })] }), e.jsxs("div", { className: "flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0", children: [e.jsx(x, { size: "icon", variant: "ghost", onClick: () => c(!0), title: "Edit entry (global \u2014 affects all models)", "data-testid": `button-edit-model-entry-${s.id}`, children: e.jsx(ae, { className: "w-3 h-3" }) }), e.jsx(x, { size: "icon", variant: "ghost", onClick: () => j(s.id), title: "Remove from this model", "data-testid": `button-remove-model-entry-${s.id}`, children: e.jsx(R, { className: "w-3 h-3" }) })] })] });
196}
197function ts({ modelId: s, modelName: p }) {
198  const { toast: h } = K(), [j, v] = u.useState(!1), { data: g = [], isLoading: c } = q({ queryKey: ["/api/admin/models", s, "frame-specs"], queryFn: async () => {
199    const m = await fetch(`/api/admin/models/${s}/frame-specs`);
200    if (!m.ok) throw new Error("Failed to load");
201    return m.json();
202  } }), n = new Set(g.flatMap((m) => m.entries.map((r) => r.id))), l = g.reduce((m, r) => m + r.entries.length, 0), t = S({ mutationFn: async (m) => k("POST", `/api/admin/models/${s}/frame-specs/remove`, { entryIds: [m] }), onSuccess: () => {
203    C.invalidateQueries({ queryKey: ["/api/admin/models", s, "frame-specs"] }), h({ title: "Spec removed from model" });
204  }, onError: () => h({ title: "Failed to remove spec", variant: "destructive" }) }), d = S({ mutationFn: async ({ specTypeId: m, ids: r }) => k("POST", "/api/admin/frame-specs/entries/reorder", { specTypeId: m, ids: r }), onSuccess: (m, { specTypeId: r }) => {
205    C.invalidateQueries({ queryKey: ["/api/admin/models", s, "frame-specs"] }), C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types", r, "entries"] });
206  }, onError: () => h({ title: "Failed to reorder", variant: "destructive" }) }), y = S({ mutationFn: async ({ id: m, data: r }) => (await k("PATCH", `/api/admin/frame-specs/entries/${m}`, r)).json(), onSuccess: () => {
207    C.invalidateQueries({ queryKey: ["/api/admin/models", s, "frame-specs"] }), h({ title: "Entry updated (affects all models)" });
208  }, onError: () => h({ title: "Update failed", variant: "destructive" }) });
209  return c ? e.jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center", children: [e.jsx(G, { className: "w-4 h-4 animate-spin" }), "Loading specs\u2026"] }) : e.jsxs("div", { className: "flex flex-col h-full", children: [e.jsxs("div", { className: "flex items-center justify-between gap-2 mb-4 flex-wrap", children: [e.jsxs("div", { children: [e.jsx("h3", { className: "font-semibold", children: p }), e.jsxs("p", { className: "text-xs text-muted-foreground mt-0.5", children: [l, " spec", l !== 1 ? "s" : "", " across ", g.length, " categor", g.length !== 1 ? "ies" : "y"] })] }), e.jsxs(x, { size: "sm", variant: "outline", onClick: () => v(!0), "data-testid": "button-open-add-specs", children: [e.jsx(V, { className: "w-3 h-3 mr-1" }), "Add Specs"] })] }), l === 0 ? e.jsx(A, { children: e.jsxs(I, { className: "py-10 text-center", children: [e.jsxs("p", { className: "text-sm text-muted-foreground", children: ["No specs assigned to ", p, " yet."] }), e.jsxs(x, { size: "sm", className: "mt-3", onClick: () => v(!0), "data-testid": "button-add-first-spec", children: [e.jsx(V, { className: "w-3 h-3 mr-1" }), "Add Specs"] })] }) }) : e.jsx(se, { className: "flex-1", children: e.jsx("div", { className: "space-y-3 pr-1", children: g.map((m) => e.jsx(as, { category: m, onRemove: (r) => t.mutate(r), onReorder: (r, i) => d.mutate({ specTypeId: r, ids: i }), onUpdate: (r, i) => y.mutate({ id: r, data: i }) }, m.specTypeId)) }) }), e.jsx(es, { open: j, onClose: () => v(!1), modelId: s, modelName: p, assignedEntryIds: n })] });
210}
211function as({ category: s, onRemove: p, onReorder: h, onUpdate: j }) {
212  const [v, g] = u.useState(!0);
213  return e.jsx(A, { "data-testid": `model-category-${s.specTypeId}`, children: e.jsxs(xe, { open: v, onOpenChange: g, children: [e.jsx(he, { asChild: !0, children: e.jsxs(X, { className: "cursor-pointer hover-elevate flex flex-row items-center justify-between gap-2 py-3", children: [e.jsxs("div", { className: "flex items-center gap-2 flex-1 min-w-0", children: [e.jsx(Z, { className: "text-sm font-medium", children: s.specTypeName }), e.jsx(L, { variant: "outline", className: "text-xs no-default-active-elevate", children: s.entr
213ies.length })] }), v ? e.jsx(me, { className: "w-4 h-4 text-muted-foreground flex-shrink-0" }) : e.jsx(ue, { className: "w-4 h-4 text-muted-foreground flex-shrink-0" })] }) }), e.jsx(ge, { children: e.jsx(I, { className: "pt-0 space-y-0.5", children: s.entries.map((c) => e.jsx(ss, { entry: c, categoryEntries: s.entries, onReorder: (n) => h(s.specTypeId, n), onRemove: p, onUpdate: j }, c.id)) }) })] }) });
214}
215function ns({ bikeModels: s }) {
216  const [p, h] = u.useState(s.length > 0 ? s[0].id : null), [j, v] = u.useState(""), g = s.filter((n) => n.name.toLowerCase().includes(j.toLowerCase())), c = s.find((n) => n.id === p) ?? null;
217  return e.jsxs("div", { className: "flex gap-4 h-dvh-minus-280 min-h-[400px]", children: [e.jsxs("div", { className: "w-52 flex-shrink-0 flex flex-col gap-2", children: [e.jsxs("div", { className: "relative", children: [e.jsx(pe, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" }), e.jsx(E, { placeholder: "Search models\u2026", value: j, onChange: (n) => v(n.target.value), className: "pl-8", "data-testid": "input-model-search" })] }), e.jsx(se, { className: "flex-1 border rounded-md", children: e.jsx("div", { className: "p-1", children: g.length === 0 ? e.jsx("p", { className: "text-xs text-muted-foreground px-2 py-3 text-center", children: "No models found" }) : g.map((n) => e.jsx("button", { type: "button", onClick: () => h(n.id), className: `w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${n.id === p ? "bg-accent text-accent-foreground font-medium" : "hover-elevate"}`, "data-testid": `model-list-item-${n.id}`, children: n.name }, n.id)) }) })] }), e.jsx("div", { className: "flex-1 min-w-0 flex flex-col", children: c ? e.jsx(ts, { modelId: c.id, modelName: c.name }) : e.jsx("div", { className: "flex items-center justify-center h-full text-sm text-muted-foreground", children: "Select a model to view its specs" }) })] });
218}
219function xs() {
220  const { toast: s } = K(), [p, h] = u.useState("category"), [j, v] = u.useState(!1), [g, c] = u.useState(""), { data: n = [], isLoading: l } = q({ queryKey: ["/api/admin/frame-specs/types"], queryFn: async () => {
221    const i = await fetch("/api/admin/frame-specs/types");
222    if (!i.ok) throw new Error("Failed to load spec types");
223    return i.json();
224  } }), { data: t = [] } = q({ queryKey: ["/api/admin/bike-models"], queryFn: async () => {
225    const i = await fetch("/api/admin/bike-models");
226    return i.ok ? (await i.json() ?? []).map((D) => ({ id: String(D.id), name: D.name })) : [];
227  } }), d = new Map(t.map((i) => [i.id, i.name])), y = S({ mutationFn: async () => (await k("POST", "/api/admin/frame-specs/dedup", {})).json(), onSuccess: (i) => {
228    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), s({ title: "Deduplication complete", description: i.entriesMerged > 0 ? `Merged ${i.entriesMerged} duplicate entries, reassigned ${i.assignmentsReassigned} model assignments.` : "No duplicates found \u2014 library is already clean." });
229  }, onError: () => s({ title: "Deduplication failed", variant: "destructive" }) }), m = S({ mutationFn: async (i) => (await k("POST", "/api/admin/frame-specs/types", { name: i, displayOrder: n.length })).json(), onSuccess: () => {
230    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), c(""), v(!1), s({ title: "Category created" });
231  }, onError: () => s({ title: "Failed to create category", variant: "destructive" }) }), r = S({ mutationFn: async (i) => k("POST", "/api/admin/frame-specs/types/reorder", { ids: i }), onSuccess: () => {
232    C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] });
233  }, onError: () => s({ title: "Failed to reorder categories", variant: "destructive" }) });
234  return e.jsxs("div", { className: "space-y-6", children: [e.jsxs("div", { className: "flex items-center justify-between flex-wrap gap-2", children: [e.jsxs("div", { children: [e.jsx("h1", { className: "text-2xl font-semibold", children: "Frame Specifications" }), e.jsx("p", { className: "text-sm text-muted-foreground mt-1", children: p === "category" ? "Manage the global spec library and assign specs to models" : "View and manage specs assigned to each model" })] }), e.jsxs("div", { className: "flex items-center gap-2", children: [e.jsxs("div", { className: "flex items-center border rounded-md overflow-hidden", children: [e.jsxs(x, { variant: "ghost", size: "sm", onClick: () => h("category"), className: `rounded-none border-r ${p === "category" ? "bg-accent text-accent-foreground" : ""}`, "data-testid": "button-view-category", children: [e.jsx(Ue, { className: "w-3.5 h-3.5 mr-1.5" }), "Category"] }), e.jsxs(x, { variant: "ghost", size: "sm", onClick: () => h("model"), className: `rounded-none ${p === "model" ? "bg-accent text-accent-foreground" : ""}`, "data-testid": "button-view-model", children: [e.jsx(He, { className: "w-3.5 h-3.5 mr-1.5" }), "Model"] })] }), p === "category" && e.jsxs(e.Fragment, { children: [e.jsxs(x, { variant: "outline", onClick: () => y.mutate(), disabled: y.isPending, title: "Merge duplicate spec entries that have the same category, label and value", "data-testid": "button-dedup", children: [y.isPending ? e.jsx(G, { className: "w-4 h-4 mr-2 animate-spin" }) : e.jsx(Ze, { className: "w-4 h-4 mr-2" }), "Fix Duplicates"] }), e.jsxs(x, { onClick: () => v(!0), "data-testid": "button-add-category", children: [e.jsx(V, { className: "w-4 h-4 mr-2" }), "Add Category"] })] })] })] }), p === "category" && e.jsxs(e.Fragment, { children: [e.jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [e.jsx(_e, {}), e.jsx(Ye, {})] }), j && e.jsx(A, { children: e.jsx(I, { className: "pt-4", children: e.jsxs("div", { className: "flex gap-2 items-center", children: [e.jsx(E, { placeholder: "Category name (e.g. Bottom Bracket)", value: g, onChange: (i) => c(i.target.value), autoFocus: !0, "data-testid": "input-new-category-name", onKeyDown: (i) => {
235    i.key === "Enter" && g.trim() && m.mutate(g.trim());
236  } }), e.jsx(x, { onClick: () => m.mutate(g.trim()), disabled: !g.trim() || m.isPending, "data-testid": "button-confirm-new-category", children: "Create" }), e.jsx(x, { variant: "outline", onClick: () => {
237    v(!1), c("");
238  }, children: "Cancel" })] }) }) }), l ? e.jsx("p", { className: "text-sm text-muted-foreground", children: "Loading c
238ategories..." }) : n.length === 0 ? e.jsx(A, { children: e.jsx(I, { className: "py-12 text-center", children: e.jsx("p", { className: "text-muted-foreground text-sm", children: 'No spec categories yet. Use "Bulk Import" above or "Add Category" to get started.' }) }) }) : e.jsxs("div", { className: "space-y-3", children: [e.jsx("p", { className: "text-xs text-muted-foreground", children: "Drag the grip handle on any category to reorder. Drag entries within a category to reorder them." }), n.map((i) => e.jsx(We, { specType: i, allTypes: n, modelNameMap: d, onDeleted: () => C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), onUpdated: () => C.invalidateQueries({ queryKey: ["/api/admin/frame-specs/types"] }), onReorderTypes: (M) => r.mutate(M) }, i.id))] })] }), p === "model" && (t.length === 0 ? e.jsx(A, { children: e.jsx(I, { className: "py-12 text-center", children: e.jsx("p", { className: "text-muted-foreground text-sm", children: "No bike models found." }) }) }) : e.jsx(ns, { bikeModels: t }))] });
239}
240export { xs as default };

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.