Product editor tweaks

This commit is contained in:
2026-01-30 22:21:44 -05:00
parent 003e1ddd61
commit ac39257a51
195 changed files with 1233 additions and 66705 deletions
+363 -18
View File
@@ -32,8 +32,8 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
@@ -2469,14 +2469,14 @@
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.2.tgz",
"integrity": "sha512-lntKchNWx3aCHuWKiDY+8WudiegQvBpDRAYL8dKLRvKEH8VOpl0XX6SSU/bUBqIRJbcTy4+MW06Wv8vgp10rzQ==",
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-controllable-state": "1.1.0"
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
@@ -2494,18 +2494,18 @@
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz",
"integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==",
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-roving-focus": "1.1.2",
"@radix-ui/react-toggle": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0"
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
@@ -2522,6 +2522,318 @@
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",
@@ -2607,6 +2919,39 @@
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
+2 -2
View File
@@ -36,8 +36,8 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
@@ -43,13 +43,13 @@ export function EditableComboboxField({
type="button"
disabled={disabled}
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"flex h-auto w-full items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"hover:border-input hover:bg-muted/50 transition-colors",
disabled && "opacity-50 cursor-not-allowed",
)}
>
{label && (
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
)}
<span className={cn("truncate flex-1", !selectedLabel && "text-muted-foreground")}>
{selectedLabel ?? placeholder}
@@ -1,5 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Copy } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
export function EditableInput({
@@ -12,6 +14,9 @@ export function EditableInput({
className,
inputClassName,
maxLength,
copyable,
alwaysShowCopy,
formatDisplay,
}: {
value: string;
onChange: (val: string) => void;
@@ -22,6 +27,9 @@ export function EditableInput({
className?: string;
inputClassName?: string;
maxLength?: number;
copyable?: boolean;
alwaysShowCopy?: boolean;
formatDisplay?: (val: string) => string;
}) {
const [editing, setEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -36,7 +44,7 @@ export function EditableInput({
return (
<div
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm",
"flex h-auto items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm",
className,
)}
>
@@ -59,28 +67,45 @@ export function EditableInput({
}}
placeholder={placeholder}
maxLength={maxLength}
className={cn("border-0 p-0 h-auto shadow-none focus-visible:ring-0", inputClassName)}
size={Math.max((value?.length || placeholder?.length || 1) + 1, 3)}
className={cn("border-0 p-0 h-auto shadow-none focus-visible:ring-0 w-auto min-w-0", inputClassName)}
/>
</div>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
<div
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"hover:border-input hover:bg-muted/50 transition-colors",
"flex h-auto items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"hover:border-input hover:bg-muted/50 transition-colors group",
className,
)}
>
{label && (
<span className="text-muted-foreground shrink-0">{label}</span>
<button
type="button"
onClick={() => setEditing(true)}
className="flex items-center gap-1.5 flex-1 min-w-0"
>
{label && (
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
)}
<span className={cn("truncate", !value && "text-muted-foreground")}>
{value ? (formatDisplay ? formatDisplay(value) : value) : placeholder}
</span>
</button>
{copyable && value && (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(value);
toast.success(`Copied ${label ?? "value"}`);
}}
className={cn("shrink-0 transition-opacity", alwaysShowCopy ? "opacity-50 hover:opacity-100" : "opacity-0 group-hover:opacity-50 hover:!opacity-100")}
>
<Copy className="h-3 w-3" />
</button>
)}
<span className={cn("truncate", !value && "text-muted-foreground")}>
{value || placeholder}
</span>
</button>
</div>
);
}
@@ -84,50 +84,37 @@ export function EditableMultiSelect({
type="button"
onClick={() => setOpen(true)}
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"hover:border-input hover:bg-muted/50 transition-colors",
)}
>
{label && (
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-muted-foreground text-xs mb-0.5">{label}</span>
)}
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground truncate">
{placeholder ?? "—"}
</span>
) : showColors ? (
<span className="flex items-center gap-1 truncate">
{selectedLabels.slice(0, 8).map((s) =>
s.hex ? (
<span
key={s.value}
className={cn(
"inline-block h-3 w-3 rounded-full shrink-0",
isWhite(s.hex) && "border border-black"
)}
style={{ backgroundColor: s.hex }}
title={s.label}
/>
) : (
<Badge key={s.value} variant="secondary" className="text-xs py-0">
{s.label}
</Badge>
)
)}
{selectedLabels.length > 8 && (
<span className="text-muted-foreground text-xs">
+{selectedLabels.length - 8}
</span>
)}
</span>
) : (
<span className="flex items-center gap-1 truncate">
<Badge variant="secondary" className="text-xs py-0 shrink-0">
{selectedLabels.length}
</Badge>
<span className="truncate">
{selectedLabels.map((s) => s.label).join(", ")}
</span>
<span className="flex flex-wrap gap-1 w-full">
{selectedLabels.map((s) => (
<Badge
key={s.value}
variant="secondary"
className="text-[11px] py-0 px-1.5 gap-1 shrink-0 font-normal"
>
{s.hex && (
<span
className={cn(
"inline-block h-2.5 w-2.5 rounded-full shrink-0",
isWhite(s.hex) && "border border-black"
)}
style={{ backgroundColor: s.hex }}
/>
)}
{s.label}
</Badge>
))}
</span>
)}
</button>
@@ -4,13 +4,18 @@ import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Loader2,
Eye,
EyeOff,
Trash2,
ZoomIn,
ImagePlus,
Plus,
Upload,
Link,
} from "lucide-react";
import {
@@ -29,15 +34,15 @@ import {
DragOverlay,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
rectSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ProductImage } from "./types";
// ── Helper: get best image URL ─────────────────────────────────────────
@@ -54,15 +59,21 @@ export function getImageSrc(img: ProductImage): string | null {
);
}
// ── Sortable Image Card ────────────────────────────────────────────────
// ── Sortable Image Cell ─────────────────────────────────────────────────
function SortableImageCard({
function SortableImageCell({
image,
index,
isMain,
showOrder,
onToggleHidden,
onDelete,
onZoom,
}: {
image: ProductImage;
index: number;
isMain: boolean;
showOrder: boolean;
onToggleHidden: (iid: number | string) => void;
onDelete: (iid: number | string) => void;
onZoom: (image: ProductImage) => void;
@@ -71,90 +82,100 @@ function SortableImageCard({
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: image.iid });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
touchAction: "none" as const,
};
const didDragRef = useRef(false);
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
const src = getImageSrc(image);
if (!src) return null;
const iconSize = isMain ? "h-4 w-4" : "h-4 w-4";
const btnPad = isMain ? "p-1" : "p-0.5";
const btnGap = isMain ? "gap-1" : "gap-0.5";
const orderSize = "w-7 h-7 text-base";
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onPointerDown={(e) => {
didDragRef.current = false;
pointerStartRef.current = { x: e.clientX, y: e.clientY };
listeners?.onPointerDown?.(e);
}}
onPointerMove={(e) => {
if (pointerStartRef.current) {
const dx = e.clientX - pointerStartRef.current.x;
const dy = e.clientY - pointerStartRef.current.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
didDragRef.current = true;
}
}
}}
onClick={() => {
if (!didDragRef.current) {
onZoom(image);
}
}}
className={cn(
"relative group rounded-lg border bg-white shrink-0 cursor-grab active:cursor-grabbing",
"w-[140px] h-[140px]",
image.hidden && "opacity-50 ring-2 ring-yellow-400"
"relative group border bg-white cursor-grab active:cursor-grabbing transition-opacity",
isMain ? "rounded-lg col-span-2 row-span-2" : "rounded-md",
image.hidden && "opacity-50 ring-2 ring-yellow-400",
isDragging && "opacity-30",
)}
style={{ touchAction: "none" }}
>
{/* Action buttons */}
<div className="absolute top-1 right-1 z-10 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className={cn("absolute top-0.5 right-0.5 z-10 flex opacity-0 group-hover:opacity-100 transition-opacity", btnGap)}>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onZoom(image);
}}
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
title="View full size"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onToggleHidden(image.iid);
}}
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
onClick={(e) => { e.stopPropagation(); onToggleHidden(image.iid); }}
className={cn(btnPad, "rounded bg-black/40 text-white hover:bg-black/60")}
title={image.hidden ? "Show image" : "Hide image"}
>
{image.hidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
{image.hidden ? <EyeOff className={iconSize} /> : <Eye className={iconSize} />}
</button>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onDelete(image.iid);
}}
className="p-1 rounded bg-red-500/80 text-white hover:bg-red-600"
onClick={(e) => { e.stopPropagation(); onDelete(image.iid); }}
className={cn(btnPad, "rounded bg-red-500/80 text-white hover:bg-red-600")}
title="Delete image"
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className={iconSize} />
</button>
</div>
{/* Hidden badge */}
{image.hidden && (
<div className="absolute bottom-1 left-1 z-10">
<Badge variant="outline" className="text-[10px] bg-yellow-100 border-yellow-400 px-1 py-0">
<div className="absolute bottom-0.5 right-0.5 z-10">
<Badge variant="outline" className={cn(
"bg-yellow-100 border-yellow-400 px-1 py-0 leading-tight",
isMain ? "text-sm" : "text-xs"
)}>
Hidden
</Badge>
</div>
)}
{showOrder && (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
<span className={cn("flex items-center justify-center rounded-full bg-black/30 text-white font-medium", orderSize)}>
{index}
</span>
</div>
)}
<img
src={src}
alt={`Image ${image.iid}`}
className="w-full h-full object-contain rounded-lg pointer-events-none select-none"
className={cn(
"w-full h-full object-cover pointer-events-none select-none",
isMain ? "rounded-lg" : "rounded-md"
)}
draggable={false}
/>
</div>
@@ -169,15 +190,19 @@ export function ImageManager({
images,
setImages,
isLoading,
maxRows = 3.2,
}: {
images: ProductImage[];
setImages: React.Dispatch<React.SetStateAction<ProductImage[]>>;
isLoading: boolean;
maxRows?: number;
}) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [zoomImage, setZoomImage] = useState<ProductImage | null>(null);
const [urlInput, setUrlInput] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const sensors = useSensors(
@@ -193,21 +218,29 @@ export function ImageManager({
setActiveId(event.active.id as number | string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveId(null);
// Live reorder during drag so the grid layout updates immediately
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setImages((prev) => {
const oldIndex = prev.findIndex((img) => img.iid === active.id);
const newIndex = prev.findIndex((img) => img.iid === over.id);
if (oldIndex === newIndex) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
},
[setImages]
);
const handleDragEnd = useCallback(
(_event: DragEndEvent) => {
setActiveId(null);
},
[]
);
const toggleHidden = useCallback(
(iid: number | string) => {
setImages((prev) =>
@@ -246,6 +279,7 @@ export function ImageManager({
async (files: FileList | null) => {
if (!files || files.length === 0) return;
setIsUploading(true);
setAddOpen(false);
try {
for (const file of Array.from(files)) {
const formData = new FormData();
@@ -270,6 +304,7 @@ export function ImageManager({
if (!url) return;
addNewImage(url);
setUrlInput("");
setAddOpen(false);
}, [urlInput, addNewImage]);
const activeImage = activeId
@@ -286,80 +321,119 @@ export function ImageManager({
const imageIds = images.map((img) => img.iid);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Images ({images.length})
</h3>
{/* Add by URL */}
<div className="flex gap-2 items-center">
<Link className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
placeholder="Add image by URL..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())}
className="text-sm h-8"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUrlAdd}
disabled={!urlInput.trim()}
className="h-8 shrink-0"
>
Add
</Button>
</div>
</div>
const CELL = 100;
const GAP = 6;
const gridSize = CELL * 3 + GAP * 2;
{/* Image grid with drag-and-drop */}
const maxHeight = CELL * maxRows + GAP * (maxRows - 1);
return (
<div style={{ width: gridSize }}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={imageIds} strategy={horizontalListSortingStrategy}>
<div className="flex gap-3 flex-wrap">
{images.map((img) => (
<SortableImageCard
<SortableContext items={imageIds} strategy={rectSortingStrategy}>
<div
className="grid overflow-y-auto"
style={{
gridTemplateColumns: `repeat(3, ${CELL}px)`,
gridAutoRows: CELL,
gap: GAP,
maxHeight,
}}
>
{images.map((img, i) => (
<SortableImageCell
key={img.iid}
image={img}
index={i + 1}
isMain={i === 0}
showOrder={!!activeId}
onToggleHidden={toggleHidden}
onDelete={deleteImage}
onZoom={setZoomImage}
/>
))}
{/* Add image button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-[140px] h-[140px] rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<ImagePlus className="h-6 w-6" />
<span className="text-xs">Upload Image</span>
</>
)}
</button>
{/* Add button with file drop */}
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isUploading}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
handleFileUpload(e.dataTransfer.files);
}}
className={cn(
"rounded-md border-2 border-dashed flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
isDragOver
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-5 w-5" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3 space-y-2" align="start">
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-start gap-2"
onClick={() => {
fileInputRef.current?.click();
}}
>
<Upload className="h-4 w-4" />
Upload Image
</Button>
<div className="flex gap-1.5 items-center">
<Link className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
placeholder="Image URL..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUrlAdd();
}
}}
className="text-sm h-7"
/>
<Button
type="button"
size="sm"
onClick={handleUrlAdd}
disabled={!urlInput.trim()}
className="h-7 px-2 shrink-0"
>
Add
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</SortableContext>
<DragOverlay>
{activeImage ? (
<div className="w-[140px] h-[140px] rounded-lg border bg-white shadow-lg">
<div className="rounded-md border bg-white shadow-lg" style={{ width: CELL, height: CELL }}>
<img
src={getImageSrc(activeImage) ?? ""}
alt="Dragging"
className="w-full h-full object-contain rounded-lg"
className="w-full h-full object-cover rounded-md"
/>
</div>
) : null}
@@ -3,11 +3,12 @@ import axios from "axios";
import { useForm, Controller } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, X, Copy } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { EditableComboboxField } from "./EditableComboboxField";
import { EditableInput } from "./EditableInput";
@@ -34,12 +35,15 @@ interface FieldConfig {
searchPlaceholder?: string;
maxLength?: number;
rows?: number;
copyable?: boolean;
/** For combobox: key into fieldOptions, or "lines"/"sublines" for dynamic */
optionsKey?: keyof FieldOptions | "lines" | "sublines";
/** For combobox: disable when this other field is empty */
disabledUnless?: keyof ProductFormValues;
/** For multiselect colors field */
showColors?: boolean;
/** Format value for display (editing shows raw value) */
formatDisplay?: (val: string) => string;
}
interface FieldGroup {
@@ -48,83 +52,104 @@ interface FieldGroup {
fields: FieldConfig[];
}
// --- Default field layout ---
// --- Layout modes ---
const DEFAULT_LAYOUT: FieldGroup[] = [
{
label: "Taxonomy",
cols: 3,
fields: [
{ key: "company", label: "Company", type: "combobox", optionsKey: "companies", searchPlaceholder: "Search companies..." },
{ key: "line", label: "Line", type: "combobox", optionsKey: "lines", searchPlaceholder: "Search lines...", disabledUnless: "company" },
{ key: "subline", label: "Sub Line", type: "combobox", optionsKey: "sublines", searchPlaceholder: "Search sublines...", disabledUnless: "line" },
],
},
{
cols: 3,
fields: [
{ key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
{ key: "tax_cat", label: "Tax Category", type: "combobox", optionsKey: "taxCategories" },
{ key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
],
},
{
label: "Identifiers",
cols: 4,
fields: [
{ key: "upc", label: "UPC", type: "input" },
{ key: "item_number", label: "Item #", type: "input" },
{ key: "supplier_no", label: "Supplier #", type: "input" },
{ key: "notions_no", label: "Notions #", type: "input" },
],
},
{
label: "Pricing",
cols: 4,
fields: [
{ key: "msrp", label: "MSRP", type: "input" },
{ key: "cost_each", label: "Cost", type: "input" },
{ key: "qty_per_unit", label: "Min Qty", type: "input" },
{ key: "case_qty", label: "Case Pack", type: "input" },
],
},
{
label: "Dimensions",
cols: 4,
fields: [
{ key: "weight", label: "Weight (oz)", type: "input" },
{ key: "length", label: "Length (in)", type: "input" },
{ key: "width", label: "Width (in)", type: "input" },
{ key: "height", label: "Height (in)", type: "input" },
],
},
{
cols: 3,
fields: [
{ key: "size_cat", label: "Size Category", type: "combobox", optionsKey: "sizes" },
{ key: "coo", label: "Country of Origin", type: "input", placeholder: "2-letter code", maxLength: 2 },
{ key: "hts_code", label: "HTS Code", type: "input" },
],
},
{
label: "Classification",
cols: 3,
fields: [
{ key: "categories", label: "Categories", type: "multiselect", optionsKey: "categories", searchPlaceholder: "Search categories..." },
{ key: "themes", label: "Themes", type: "multiselect", optionsKey: "themes", searchPlaceholder: "Search themes..." },
{ key: "colors", label: "Colors", type: "multiselect", optionsKey: "colors", searchPlaceholder: "Search colors...", showColors: true },
],
},
{
label: "Description",
cols: 1,
fields: [
{ key: "description", label: "Description", type: "textarea", rows: 4 },
{ key: "priv_notes", label: "Private Notes", type: "textarea", rows: 2 },
],
},
type LayoutMode = "full" | "minimal" | "shop" | "backend";
const LAYOUT_ICONS: { mode: LayoutMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ mode: "full", label: "Full", icon: Maximize2 },
{ mode: "minimal", label: "Minimal", icon: Minus },
{ mode: "shop", label: "Shop", icon: Store },
{ mode: "backend", label: "Backend", icon: Terminal },
];
// --- Field registry (all available fields) ---
const F: Record<string, FieldConfig> = {
company: { key: "company", label: "Company", type: "combobox", optionsKey: "companies", searchPlaceholder: "Search companies..." },
line: { key: "line", label: "Line", type: "combobox", optionsKey: "lines", searchPlaceholder: "Search lines...", disabledUnless: "company" },
subline: { key: "subline", label: "Subline", type: "combobox", optionsKey: "sublines", searchPlaceholder: "Search sublines...", disabledUnless: "line" },
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
ship: { key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
msrp: { key: "msrp", label: "MSRP", type: "input" },
cost: { key: "cost_each", label: "Cost", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
weight: { key: "weight", label: "Weight (oz)", type: "input" },
length: { key: "length", label: "Length (in)", type: "input" },
width: { key: "width", label: "Width (in)", type: "input" },
height: { key: "height", label: "Height (in)", type: "input" },
size_cat: { key: "size_cat", label: "Size Cat", type: "combobox", optionsKey: "sizes" },
coo: { key: "coo", label: "Country of Origin", type: "input", placeholder: "2-letter code", maxLength: 2 },
hts_code: { key: "hts_code", label: "HTS Code", type: "input" },
categories: { key: "categories", label: "Categories", type: "multiselect", optionsKey: "categories", searchPlaceholder: "Search categories..." },
themes: { key: "themes", label: "Themes", type: "multiselect", optionsKey: "themes", searchPlaceholder: "Search themes..." },
colors: { key: "colors", label: "Colors", type: "multiselect", optionsKey: "colors", searchPlaceholder: "Search colors...", showColors: true },
supplier: { key: "supplier", label: "Supplier", type: "combobox", optionsKey: "suppliers", searchPlaceholder: "Search suppliers..." },
description: { key: "description", label: "Description", type: "textarea", rows: 8 },
priv_notes: { key: "priv_notes", label: "Private Notes", type: "textarea", rows: 2 },
};
// --- Per-mode layouts with fields combined into sensible rows ---
interface ModeLayout {
/** Number of leading groups to render beside images (max cols 2 in sidebar) */
sidebarGroups: number;
/** Max visible rows in the image grid */
imageRows?: number;
/** Override description textarea rows */
descriptionRows?: number;
groups: FieldGroup[];
}
const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
full: {
sidebarGroups: 3,
groups: [
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
{ cols: 2, fields: [F.artist, F.size_cat] },
{ label: "Description", cols: 1, fields: [F.description] },
{ label: "Pricing", cols: 4, fields: [F.msrp, F.cost, F.min_qty, F.case_qty] },
{ label: "Dimensions", cols: 4, fields: [F.weight, F.length, F.width, F.height] },
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
],
},
shop: {
sidebarGroups: 3,
descriptionRows: 8,
groups: [
{ label: "Taxonomy", cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
{ cols: 2, fields: [F.artist, F.size_cat] },
{ label: "Description", cols: 1, fields: [F.description] },
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
],
},
backend: {
sidebarGroups: 5,
groups: [
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty, F.cost, F.msrp] },
{ cols: 3, fields: [F.case_qty, F.size_cat, F.weight] },
{ label: "Dimensions", cols: 3, fields: [ F.length, F.width, F.height] },
{ cols: 2, fields: [F.tax_cat, F.ship, F.coo, F.hts_code] },
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
],
},
minimal: {
sidebarGroups: 3,
imageRows: 2.2,
descriptionRows: 6,
groups: [
{ cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
{ label: "Description", cols: 1, fields: [F.description] },
],
},
};
export function ProductEditForm({
product,
fieldOptions,
@@ -134,6 +159,7 @@ export function ProductEditForm({
fieldOptions: FieldOptions;
onClose: () => void;
}) {
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
const [productImages, setProductImages] = useState<ProductImage[]>([]);
@@ -352,172 +378,213 @@ export function ProductEditForm({
const hasImageChanges = computeImageChanges() !== null;
const changedCount = Object.keys(dirtyFields).length;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
const renderFieldGroup = (group: FieldGroup, gi: number) => (
<div
key={gi}
className="grid gap-x-2 gap-y-2 p-1 rounded-md"
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
>
{group.fields.map((fc) => {
if (fc.type === "input") {
return (
<Controller
name="name"
key={fc.key}
name={fc.key}
control={control}
render={({ field }) => (
<EditableInput
value={field.value}
label={fc.label}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Product name"
className="text-lg font-semibold border-none"
inputClassName="text-lg font-semibold"
placeholder={fc.placeholder ?? "—"}
maxLength={fc.maxLength}
copyable={fc.copyable}
formatDisplay={fc.formatDisplay}
/>
)}
/>
</div>
<div className="flex items-center gap-2">
{(changedCount > 0 || hasImageChanges) && (
<Badge variant="secondary">
{changedCount > 0 ? `${changedCount} field${changedCount !== 1 ? "s" : ""}` : ""}
{changedCount > 0 && hasImageChanges ? " + " : ""}
{hasImageChanges ? "images" : ""}
{" changed"}
</Badge>
)}
<Button variant="ghost" size="icon" onClick={onClose}>
);
}
if (fc.type === "combobox") {
return (
<Controller
key={fc.key}
name={fc.key}
control={control}
render={({ field }) => (
<EditableComboboxField
label={fc.label}
options={getOptions(fc.optionsKey)}
value={field.value as string}
onChange={field.onChange}
placeholder="—"
searchPlaceholder={fc.searchPlaceholder}
disabled={fc.disabledUnless ? !watch(fc.disabledUnless) : false}
/>
)}
/>
);
}
if (fc.type === "multiselect") {
return (
<Controller
key={fc.key}
name={fc.key}
control={control}
render={({ field }) => (
<EditableMultiSelect
label={fc.label}
options={getOptions(fc.optionsKey)}
value={(field.value as string[]) ?? []}
onChange={field.onChange}
placeholder="—"
searchPlaceholder={fc.searchPlaceholder}
showColors={fc.showColors}
/>
)}
/>
);
}
if (fc.type === "textarea") {
return (
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
<Textarea {...register(fc.key)} rows={(fc.key === "description" && MODE_LAYOUTS[layoutMode].descriptionRows) || fc.rows || 3} className="border-0 p-0 h-auto shadow-none focus-visible:ring-0 resize-y text-sm min-h-0" />
</div>
);
}
return null;
})}
</div>
);
return (
<Card>
<CardHeader>
{/* Layout toggle */}
<div className="flex items-center justify-end mb-3">
<ToggleGroup
type="single"
value={layoutMode}
onValueChange={(v) => { if (v) setLayoutMode(v as LayoutMode); }}
>
{LAYOUT_ICONS.map(({ mode, label, icon: Icon }) => (
<Tooltip key={mode}>
<TooltipTrigger asChild>
<ToggleGroupItem value={mode} size="sm" variant="outline">
<Icon className="h-4 w-4" />
<span className="text-xs">{label}</span>
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
))}
</ToggleGroup>
<Button variant="secondary" size="icon" onClick={onClose} className="ml-4">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0">
<Controller
name="name"
control={control}
render={({ field }) => (
<EditableInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Product name"
className="text-base font-semibold"
inputClassName="text-base font-semibold"
/>
)}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<a
href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors px-2"
>
<ExternalLink className="h-5 w-5" />
</a>
</TooltipTrigger>
<TooltipContent side="bottom">View in Backend</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent>
{/* Product Images */}
<div className="mb-6">
<ImageManager
images={productImages}
setImages={setProductImages}
isLoading={isLoadingImages}
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-1">
{/* Supplier + badges row */}
<div className="flex justify-between gap-1">
<div className="p-1 rounded-md">
<Controller name="supplier" control={control} render={({ field }) => (
<EditableComboboxField label="Supplier" options={fieldOptions.suppliers ?? []} value={field.value} onChange={field.onChange} placeholder="—" searchPlaceholder="Search suppliers..." />
)} />
</div>
<div className="flex gap-1">
{[
{ label: "PID", value: product.pid },
{ label: "Item #", value: product.sku },
{ label: "UPC", value: product.barcode },
...(product.vendor_reference ? [{ label: "Supplier #", value: product.vendor_reference }] : []),
...(product.notions_reference ? [{ label: "Notions #", value: product.notions_reference }] : []),
].map((item) => (
<Badge
key={item.label}
variant="outline"
className="gap-1 cursor-pointer hover:bg-muted border-none border-muted-foreground/50"
onClick={() => {
navigator.clipboard.writeText(String(item.value));
toast.success(`Copied ${item.label}`);
}}
>
{item.label}: {item.value}
<Copy className="h-3 w-3 opacity-50" />
</Badge>
<div className="flex flex-wrap gap-2 items-center justify-start">
<Badge
variant="secondary"
className="gap-1 cursor-pointer hover:bg-muted text-xs px-2.5 h-[22px] font-normal"
onClick={() => {
navigator.clipboard.writeText(String(product.pid));
toast.success("Copied PID");
}}
>
<span className="text-muted-foreground text-xs">PID</span> {product.pid}
<Copy className="h-3 w-3 opacity-50" />
</Badge>
{(["item_number", "upc", "supplier_no", "notions_no"] as const).map((key) => (
<Controller
key={key}
name={key}
control={control}
render={({ field }) => (
<EditableInput
label={{ item_number: "Item #", upc: "UPC", supplier_no: "Supplier #", notions_no: "Notions #" }[key]}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="—"
copyable
alwaysShowCopy
className="w-auto border-none py-0 px-2.5 text-xs h-[22px] rounded-full bg-secondary hover:bg-muted"
inputClassName="text-xs"
/>
)}
/>
))}
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-1">
{/* Images + sidebar fields */}
<div className="flex gap-2">
<div className="shrink-0">
<ImageManager
images={productImages}
setImages={setProductImages}
isLoading={isLoadingImages}
maxRows={MODE_LAYOUTS[layoutMode].imageRows}
/>
</div>
{MODE_LAYOUTS[layoutMode].sidebarGroups > 0 && (
<div className="flex-1 min-w-0 space-y-1">
{MODE_LAYOUTS[layoutMode].groups.slice(0, MODE_LAYOUTS[layoutMode].sidebarGroups).map((group, gi) =>
renderFieldGroup(group, gi)
)}
</div>
)}
</div>
{/* Dynamic field groups */}
{DEFAULT_LAYOUT.map((group, gi) => (
<div
key={gi}
className={`grid gap-x-4 gap-y-1 p-1 rounded-md`}
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
>
{group.fields.map((fc) => {
if (fc.type === "input") {
return (
<Controller
key={fc.key}
name={fc.key}
control={control}
render={({ field }) => (
<EditableInput
label={fc.label}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder={fc.placeholder ?? "—"}
maxLength={fc.maxLength}
/>
)}
/>
);
}
if (fc.type === "combobox") {
return (
<Controller
key={fc.key}
name={fc.key}
control={control}
render={({ field }) => (
<EditableComboboxField
label={fc.label}
options={getOptions(fc.optionsKey)}
value={field.value as string}
onChange={field.onChange}
placeholder="—"
searchPlaceholder={fc.searchPlaceholder}
disabled={fc.disabledUnless ? !watch(fc.disabledUnless) : false}
/>
)}
/>
);
}
if (fc.type === "multiselect") {
return (
<Controller
key={fc.key}
name={fc.key}
control={control}
render={({ field }) => (
<EditableMultiSelect
label={fc.label}
options={getOptions(fc.optionsKey)}
value={(field.value as string[]) ?? []}
onChange={field.onChange}
placeholder="—"
searchPlaceholder={fc.searchPlaceholder}
showColors={fc.showColors}
/>
)}
/>
);
}
if (fc.type === "textarea") {
return (
<div key={fc.key} className="col-span-full">
<Label>{fc.label}</Label>
<Textarea {...register(fc.key)} rows={fc.rows ?? 3} />
</div>
);
}
return null;
})}
</div>
))}
{/* Remaining field groups */}
{MODE_LAYOUTS[layoutMode].groups.slice(MODE_LAYOUTS[layoutMode].sidebarGroups).map((group, gi) =>
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups)
)}
{/* Submit */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<div className="flex items-center justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
if (originalValuesRef.current) {
reset(originalValuesRef.current);
@@ -529,6 +596,7 @@ export function ProductEditForm({
<Button
type="submit"
disabled={isSubmitting || (changedCount === 0 && !hasImageChanges)}
size="sm"
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -16,20 +16,32 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Search } from "lucide-react";
import type { SearchProduct } from "./types";
interface QuickSearchResult {
pid: number;
title: string;
sku: string;
barcode: string;
brand: string;
line: string;
regular_price: number;
image_175: string | null;
}
export function ProductSearch({
onSelect,
}: {
onSelect: (product: SearchProduct) => void;
}) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<SearchProduct[]>([]);
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return;
setIsSearching(true);
try {
const res = await axios.get("/api/import/search-products", {
const res = await axios.get("/api/products/search", {
params: { q: searchTerm },
});
setSearchResults(res.data);
@@ -41,9 +53,24 @@ export function ProductSearch({
}, [searchTerm]);
const handleSelect = useCallback(
(product: SearchProduct) => {
onSelect(product);
setSearchResults([]);
async (product: QuickSearchResult) => {
setIsLoadingProduct(product.pid);
try {
const res = await axios.get("/api/import/search-products", {
params: { pid: product.pid },
});
const full = (res.data as SearchProduct[])[0];
if (full) {
onSelect(full);
setSearchResults([]);
} else {
toast.error("Could not load full product details");
}
} catch {
toast.error("Failed to load product details");
} finally {
setIsLoadingProduct(null);
}
},
[onSelect]
);
@@ -87,10 +114,13 @@ export function ProductSearch({
{searchResults.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSelect(product)}
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
onClick={() => !isLoadingProduct && handleSelect(product)}
>
<TableCell className="max-w-[300px] truncate">
{isLoadingProduct === product.pid && (
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
)}
{product.title}
</TableCell>
<TableCell>{product.sku}</TableCell>