PathConfigEditor.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. "use client";
  2. import { useQuery } from "@tanstack/react-query";
  3. import { useMemo, useState } from "react";
  4. import { get } from "../../lib/api";
  5. interface PathConfigEditorProps {
  6. value: any;
  7. onChange: (value: any) => void;
  8. }
  9. export default function PathConfigEditor({
  10. value,
  11. onChange
  12. }: PathConfigEditorProps) {
  13. const [useJsonMode, setUseJsonMode] = useState(false);
  14. const [jsonValue, setJsonValue] = useState("");
  15. const [formData, setFormData] = useState<Record<string, any>>({});
  16. // Fetch handbrake presets on mount
  17. const { data: handbrakePresets = [] } = useQuery({
  18. queryKey: ["handbrake", "presets"],
  19. queryFn: () => get("/handbrake/presets"),
  20. select: (data) => (Array.isArray(data) ? data : [])
  21. });
  22. // Fetch extensions from settings
  23. const { data: extensions = [] } = useQuery({
  24. queryKey: ["config", "settings", "extensions"],
  25. queryFn: () => get("/config/settings/extensions"),
  26. select: (data) => (Array.isArray(data) ? data : [])
  27. });
  28. // Initialize form data and json value - default to form mode
  29. const derivedFormData = useMemo(() => {
  30. if (value && typeof value === "object" && !Array.isArray(value)) {
  31. return { ...value };
  32. }
  33. return {};
  34. }, [value]);
  35. const derivedJsonValue = useMemo(() => {
  36. return JSON.stringify(value || {}, null, 2);
  37. }, [value]);
  38. const handleJsonChange = (newJson: string) => {
  39. try {
  40. const parsed = JSON.parse(newJson);
  41. onChange(parsed);
  42. } catch {
  43. // Invalid JSON, don't update parent yet
  44. }
  45. };
  46. const handleFormFieldChange = (key: string, fieldValue: any) => {
  47. const newFormData = { ...derivedFormData, [key]: fieldValue };
  48. onChange(newFormData);
  49. };
  50. const addFormField = () => {
  51. const newKey = `field_${Object.keys(derivedFormData).length + 1}`;
  52. handleFormFieldChange(newKey, "");
  53. };
  54. const removeFormField = (key: string) => {
  55. const newFormData = { ...derivedFormData };
  56. delete newFormData[key];
  57. onChange(newFormData);
  58. };
  59. const switchToJsonMode = () => {
  60. setUseJsonMode(true);
  61. onChange(derivedFormData);
  62. };
  63. const switchToFormMode = () => {
  64. try {
  65. // When switching to form mode, we use the current value (which comes from props)
  66. if (typeof value === "object" && !Array.isArray(value)) {
  67. setUseJsonMode(false);
  68. return;
  69. }
  70. // If we can't convert to form mode, stay in JSON mode
  71. } catch {
  72. // Invalid JSON, stay in JSON mode
  73. }
  74. };
  75. // Render specific field types
  76. const renderFieldInput = (key: string, val: any) => {
  77. switch (key) {
  78. case "exts":
  79. return (
  80. <div className="flex-1">
  81. <select
  82. multiple
  83. value={Array.isArray(val) ? val : []}
  84. onChange={(e) => {
  85. const selected = Array.from(
  86. e.target.selectedOptions,
  87. (option) => option.value
  88. );
  89. handleFormFieldChange(key, selected);
  90. }}
  91. className="w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-xs h-24"
  92. >
  93. {extensions.map((ext) => (
  94. <option key={ext} value={ext}>
  95. {ext}
  96. </option>
  97. ))}
  98. </select>
  99. <p className="text-xs text-gray-500 mt-1">
  100. Hold Ctrl/Cmd to select multiple
  101. </p>
  102. </div>
  103. );
  104. case "ext":
  105. return (
  106. <select
  107. value={val || ""}
  108. onChange={(e) => handleFormFieldChange(key, e.target.value)}
  109. className="flex-1 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-xs"
  110. >
  111. <option value="">Select extension...</option>
  112. {extensions.map((ext) => (
  113. <option key={ext} value={ext}>
  114. {ext}
  115. </option>
  116. ))}
  117. </select>
  118. );
  119. case "preset":
  120. return (
  121. <select
  122. value={val || ""}
  123. onChange={(e) => handleFormFieldChange(key, e.target.value)}
  124. className="flex-1 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-xs"
  125. >
  126. <option value="">Select preset...</option>
  127. {handbrakePresets.map((preset) => (
  128. <option key={preset} value={preset}>
  129. {preset}
  130. </option>
  131. ))}
  132. </select>
  133. );
  134. case "clean":
  135. return (
  136. <div className="flex-1">
  137. <textarea
  138. value={
  139. typeof val === "object"
  140. ? JSON.stringify(val, null, 2)
  141. : val || ""
  142. }
  143. onChange={(e) => {
  144. try {
  145. const parsed = JSON.parse(e.target.value);
  146. handleFormFieldChange(key, parsed);
  147. } catch {
  148. handleFormFieldChange(key, e.target.value);
  149. }
  150. }}
  151. placeholder='{"regex": "replacement"}'
  152. rows={6}
  153. className="w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-xs"
  154. />
  155. <p className="text-xs text-gray-500 mt-1">
  156. JSON object with regex patterns
  157. </p>
  158. </div>
  159. );
  160. default:
  161. if (typeof val === "boolean") {
  162. return (
  163. <div className="flex items-center gap-2 flex-1">
  164. <input
  165. type="checkbox"
  166. checked={val}
  167. onChange={(e) => handleFormFieldChange(key, e.target.checked)}
  168. className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
  169. />
  170. <span className="text-xs text-gray-600 dark:text-gray-400">
  171. Boolean
  172. </span>
  173. </div>
  174. );
  175. } else if (typeof val === "number") {
  176. return (
  177. <input
  178. type="number"
  179. value={val}
  180. onChange={(e) =>
  181. handleFormFieldChange(key, parseFloat(e.target.value) || 0)
  182. }
  183. placeholder="Number value"
  184. className="flex-1 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-xs"
  185. />
  186. );
  187. } else {
  188. return (
  189. <input
  190. type="text"
  191. value={val || ""}
  192. onChange={(e) => handleFormFieldChange(key, e.target.value)}
  193. placeholder="String value"
  194. className="flex-1 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-xs"
  195. />
  196. );
  197. }
  198. }
  199. };
  200. if (useJsonMode) {
  201. return (
  202. <div className="space-y-3">
  203. <div className="flex items-center justify-between">
  204. <label className="block text-sm font-medium text-gray-700 dark:text-gray-200">
  205. Configuration (JSON)
  206. </label>
  207. <button
  208. type="button"
  209. onClick={switchToFormMode}
  210. className="text-xs text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200"
  211. >
  212. Switch to Form
  213. </button>
  214. </div>
  215. <textarea
  216. value={derivedJsonValue}
  217. onChange={(e) => handleJsonChange(e.target.value)}
  218. placeholder='{"key": "value"}'
  219. rows={6}
  220. className="w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-xs"
  221. />
  222. {(() => {
  223. try {
  224. JSON.parse(derivedJsonValue);
  225. return null;
  226. } catch {
  227. return (
  228. <p className="text-xs text-red-600 dark:text-red-400">
  229. Invalid JSON format
  230. </p>
  231. );
  232. }
  233. })()}
  234. </div>
  235. );
  236. }
  237. return (
  238. <div className="space-y-3">
  239. <div className="flex items-center justify-between">
  240. <label className="block text-sm font-medium text-gray-700 dark:text-gray-200">
  241. Configuration (Form)
  242. </label>
  243. <button
  244. type="button"
  245. onClick={switchToJsonMode}
  246. className="text-xs text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200"
  247. >
  248. Switch to JSON
  249. </button>
  250. </div>
  251. {Object.keys(derivedFormData).length === 0 ? (
  252. <p className="text-sm text-gray-500 italic text-center py-4">
  253. No configuration fields. Add a field or switch to JSON mode.
  254. </p>
  255. ) : (
  256. <div className="space-y-3">
  257. {Object.entries(derivedFormData).map(([key, val]) => (
  258. <div key={key} className="flex gap-2 items-start">
  259. <input
  260. type="text"
  261. value={key}
  262. onChange={(e) => {
  263. const newFormData = { ...derivedFormData };
  264. delete newFormData[key];
  265. newFormData[e.target.value] = val;
  266. onChange(newFormData);
  267. }}
  268. placeholder="Field name"
  269. className="w-1/3 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-xs"
  270. />
  271. {renderFieldInput(key, val)}
  272. <button
  273. type="button"
  274. onClick={() => removeFormField(key)}
  275. className="inline-flex items-center rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
  276. >
  277. ×
  278. </button>
  279. </div>
  280. ))}
  281. </div>
  282. )}
  283. <button
  284. type="button"
  285. onClick={addFormField}
  286. className="text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-200"
  287. >
  288. + Add Field
  289. </button>
  290. </div>
  291. );
  292. }