@weirdfingers/baseboards 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +14 -4
  2. package/dist/index.js +13 -4
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/api/ARTIFACT_RESOLUTION_GUIDE.md +148 -0
  6. package/templates/api/Dockerfile +2 -2
  7. package/templates/api/README.md +138 -6
  8. package/templates/api/config/generators.yaml +41 -7
  9. package/templates/api/docs/TESTING_LIVE_APIS.md +417 -0
  10. package/templates/api/pyproject.toml +49 -9
  11. package/templates/api/src/boards/__init__.py +1 -1
  12. package/templates/api/src/boards/auth/adapters/__init__.py +9 -2
  13. package/templates/api/src/boards/auth/factory.py +16 -2
  14. package/templates/api/src/boards/generators/__init__.py +2 -2
  15. package/templates/api/src/boards/generators/artifact_resolution.py +372 -0
  16. package/templates/api/src/boards/generators/artifacts.py +4 -4
  17. package/templates/api/src/boards/generators/base.py +8 -4
  18. package/templates/api/src/boards/generators/implementations/__init__.py +4 -2
  19. package/templates/api/src/boards/generators/implementations/fal/__init__.py +25 -0
  20. package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
  21. package/templates/api/src/boards/generators/implementations/fal/audio/minimax_music_v2.py +173 -0
  22. package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +221 -0
  23. package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +17 -0
  24. package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_kontext.py +216 -0
  25. package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_ultra.py +197 -0
  26. package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview.py +191 -0
  27. package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview_fast.py +179 -0
  28. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana.py +183 -0
  29. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_edit.py +212 -0
  30. package/templates/api/src/boards/generators/implementations/fal/utils.py +61 -0
  31. package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +13 -0
  32. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_text_to_video.py +168 -0
  33. package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2.py +167 -0
  34. package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +180 -0
  35. package/templates/api/src/boards/generators/implementations/openai/__init__.py +1 -0
  36. package/templates/api/src/boards/generators/implementations/openai/audio/__init__.py +1 -0
  37. package/templates/api/src/boards/generators/implementations/{audio → openai/audio}/whisper.py +9 -6
  38. package/templates/api/src/boards/generators/implementations/openai/image/__init__.py +1 -0
  39. package/templates/api/src/boards/generators/implementations/{image → openai/image}/dalle3.py +8 -5
  40. package/templates/api/src/boards/generators/implementations/replicate/__init__.py +1 -0
  41. package/templates/api/src/boards/generators/implementations/replicate/image/__init__.py +1 -0
  42. package/templates/api/src/boards/generators/implementations/{image → replicate/image}/flux_pro.py +8 -5
  43. package/templates/api/src/boards/generators/implementations/replicate/video/__init__.py +1 -0
  44. package/templates/api/src/boards/generators/implementations/{video → replicate/video}/lipsync.py +9 -6
  45. package/templates/api/src/boards/generators/resolution.py +80 -20
  46. package/templates/api/src/boards/jobs/repository.py +49 -0
  47. package/templates/api/src/boards/storage/factory.py +16 -6
  48. package/templates/api/src/boards/workers/actors.py +69 -5
  49. package/templates/api/src/boards/workers/context.py +177 -21
  50. package/templates/web/package.json +2 -1
  51. package/templates/web/src/components/boards/GenerationInput.tsx +154 -52
  52. package/templates/web/src/components/boards/GeneratorSelector.tsx +57 -59
  53. package/templates/web/src/components/ui/dropdown-menu.tsx +200 -0
  54. package/templates/api/src/boards/generators/implementations/audio/__init__.py +0 -3
  55. package/templates/api/src/boards/generators/implementations/image/__init__.py +0 -3
  56. package/templates/api/src/boards/generators/implementations/video/__init__.py +0 -3
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from uuid import UUID
5
+ from uuid import UUID, uuid4
6
6
 
7
7
  from ..database.connection import get_async_session
8
8
  from ..generators import resolution
@@ -24,12 +24,22 @@ class GeneratorExecutionContext:
24
24
  storage_manager: StorageManager,
25
25
  tenant_id: UUID,
26
26
  board_id: UUID,
27
+ user_id: UUID,
28
+ generator_name: str,
29
+ artifact_type: str,
30
+ input_params: dict,
27
31
  ) -> None:
28
32
  self.generation_id = str(generation_id)
29
33
  self.publisher = publisher
30
34
  self.storage_manager = storage_manager
31
35
  self.tenant_id = str(tenant_id)
32
36
  self.board_id = str(board_id)
37
+ self.user_id = str(user_id)
38
+ self.generator_name = generator_name
39
+ self.artifact_type = artifact_type
40
+ self.input_params = input_params
41
+ self._batch_id: str | None = None
42
+ self._batch_generations: list[str] = []
33
43
  logger.info(
34
44
  "Created execution context",
35
45
  generation_id=str(generation_id),
@@ -52,15 +62,34 @@ class GeneratorExecutionContext:
52
62
  self,
53
63
  storage_url: str,
54
64
  format: str,
55
- width: int,
56
- height: int,
65
+ width: int | None = None,
66
+ height: int | None = None,
67
+ output_index: int = 0,
57
68
  ) -> ImageArtifact:
58
- """Store image generation result."""
59
- logger.debug("Storing image result", generation_id=self.generation_id)
69
+ """Store image generation result.
70
+
71
+ Args:
72
+ storage_url: URL to download the image from
73
+ format: Image format (png, jpg, etc.)
74
+ width: Image width in pixels (optional)
75
+ height: Image height in pixels (optional)
76
+ output_index: Index of this output in a batch (0 for primary, 1+ for additional)
77
+
78
+ Returns:
79
+ ImageArtifact with the generation_id set appropriately
80
+ """
81
+ logger.debug(
82
+ "Storing image result",
83
+ generation_id=self.generation_id,
84
+ output_index=output_index,
85
+ )
60
86
  try:
87
+ # Determine which generation_id to use
88
+ target_generation_id = await self._get_or_create_generation_for_output(output_index)
89
+
61
90
  result = await resolution.store_image_result(
62
91
  storage_manager=self.storage_manager,
63
- generation_id=self.generation_id,
92
+ generation_id=target_generation_id,
64
93
  tenant_id=self.tenant_id,
65
94
  board_id=self.board_id,
66
95
  storage_url=storage_url,
@@ -68,7 +97,11 @@ class GeneratorExecutionContext:
68
97
  width=width,
69
98
  height=height,
70
99
  )
71
- logger.info("Image result stored", generation_id=self.generation_id)
100
+ logger.info(
101
+ "Image result stored",
102
+ generation_id=target_generation_id,
103
+ output_index=output_index,
104
+ )
72
105
  return result
73
106
  except Exception as e:
74
107
  logger.error("Failed to store image result", error=str(e))
@@ -78,17 +111,38 @@ class GeneratorExecutionContext:
78
111
  self,
79
112
  storage_url: str,
80
113
  format: str,
81
- width: int,
82
- height: int,
114
+ width: int | None = None,
115
+ height: int | None = None,
83
116
  duration: float | None = None,
84
117
  fps: float | None = None,
118
+ output_index: int = 0,
85
119
  ) -> VideoArtifact:
86
- """Store video generation result."""
87
- logger.debug("Storing video result", generation_id=self.generation_id)
120
+ """Store video generation result.
121
+
122
+ Args:
123
+ storage_url: URL to download the video from
124
+ format: Video format (mp4, webm, etc.)
125
+ width: Video width in pixels (optional)
126
+ height: Video height in pixels (optional)
127
+ duration: Video duration in seconds (optional)
128
+ fps: Frames per second (optional)
129
+ output_index: Index of this output in a batch (0 for primary, 1+ for additional)
130
+
131
+ Returns:
132
+ VideoArtifact with the generation_id set appropriately
133
+ """
134
+ logger.debug(
135
+ "Storing video result",
136
+ generation_id=self.generation_id,
137
+ output_index=output_index,
138
+ )
88
139
  try:
140
+ # Determine which generation_id to use
141
+ target_generation_id = await self._get_or_create_generation_for_output(output_index)
142
+
89
143
  result = await resolution.store_video_result(
90
144
  storage_manager=self.storage_manager,
91
- generation_id=self.generation_id,
145
+ generation_id=target_generation_id,
92
146
  tenant_id=self.tenant_id,
93
147
  board_id=self.board_id,
94
148
  storage_url=storage_url,
@@ -98,7 +152,11 @@ class GeneratorExecutionContext:
98
152
  duration=duration,
99
153
  fps=fps,
100
154
  )
101
- logger.info("Video result stored", generation_id=self.generation_id)
155
+ logger.info(
156
+ "Video result stored",
157
+ generation_id=target_generation_id,
158
+ output_index=output_index,
159
+ )
102
160
  return result
103
161
  except Exception as e:
104
162
  logger.error("Failed to store video result", error=str(e))
@@ -111,13 +169,33 @@ class GeneratorExecutionContext:
111
169
  duration: float | None = None,
112
170
  sample_rate: int | None = None,
113
171
  channels: int | None = None,
172
+ output_index: int = 0,
114
173
  ) -> AudioArtifact:
115
- """Store audio generation result."""
116
- logger.debug("Storing audio result", generation_id=self.generation_id)
174
+ """Store audio generation result.
175
+
176
+ Args:
177
+ storage_url: URL to download the audio from
178
+ format: Audio format (mp3, wav, etc.)
179
+ duration: Audio duration in seconds (optional)
180
+ sample_rate: Sample rate in Hz (optional)
181
+ channels: Number of audio channels (optional)
182
+ output_index: Index of this output in a batch (0 for primary, 1+ for additional)
183
+
184
+ Returns:
185
+ AudioArtifact with the generation_id set appropriately
186
+ """
187
+ logger.debug(
188
+ "Storing audio result",
189
+ generation_id=self.generation_id,
190
+ output_index=output_index,
191
+ )
117
192
  try:
193
+ # Determine which generation_id to use
194
+ target_generation_id = await self._get_or_create_generation_for_output(output_index)
195
+
118
196
  result = await resolution.store_audio_result(
119
197
  storage_manager=self.storage_manager,
120
- generation_id=self.generation_id,
198
+ generation_id=target_generation_id,
121
199
  tenant_id=self.tenant_id,
122
200
  board_id=self.board_id,
123
201
  storage_url=storage_url,
@@ -126,7 +204,11 @@ class GeneratorExecutionContext:
126
204
  sample_rate=sample_rate,
127
205
  channels=channels,
128
206
  )
129
- logger.info("Audio result stored", generation_id=self.generation_id)
207
+ logger.info(
208
+ "Audio result stored",
209
+ generation_id=target_generation_id,
210
+ output_index=output_index,
211
+ )
130
212
  return result
131
213
  except Exception as e:
132
214
  logger.error("Failed to store audio result", error=str(e))
@@ -136,19 +218,40 @@ class GeneratorExecutionContext:
136
218
  self,
137
219
  content: str,
138
220
  format: str,
221
+ output_index: int = 0,
139
222
  ) -> TextArtifact:
140
- """Store text generation result."""
141
- logger.debug("Storing text result", generation_id=self.generation_id)
223
+ """Store text generation result.
224
+
225
+ Args:
226
+ content: Text content to store
227
+ format: Text format (plain, markdown, html, etc.)
228
+ output_index: Index of this output in a batch (0 for primary, 1+ for additional)
229
+
230
+ Returns:
231
+ TextArtifact with the generation_id set appropriately
232
+ """
233
+ logger.debug(
234
+ "Storing text result",
235
+ generation_id=self.generation_id,
236
+ output_index=output_index,
237
+ )
142
238
  try:
239
+ # Determine which generation_id to use
240
+ target_generation_id = await self._get_or_create_generation_for_output(output_index)
241
+
143
242
  result = await resolution.store_text_result(
144
243
  storage_manager=self.storage_manager,
145
- generation_id=self.generation_id,
244
+ generation_id=target_generation_id,
146
245
  tenant_id=self.tenant_id,
147
246
  board_id=self.board_id,
148
247
  content=content,
149
248
  format=format,
150
249
  )
151
- logger.info("Text result stored", generation_id=self.generation_id)
250
+ logger.info(
251
+ "Text result stored",
252
+ generation_id=target_generation_id,
253
+ output_index=output_index,
254
+ )
152
255
  return result
153
256
  except Exception as e:
154
257
  logger.error("Failed to store text result", error=str(e))
@@ -186,3 +289,56 @@ class GeneratorExecutionContext:
186
289
  )
187
290
  async with get_async_session() as session:
188
291
  await jobs_repo.set_external_job_id(session, self.generation_id, external_id)
292
+
293
+ async def _get_or_create_generation_for_output(self, output_index: int) -> str:
294
+ """Get or create a generation record for the given output index.
295
+
296
+ Args:
297
+ output_index: Index of the output (0 for primary, 1+ for batch outputs)
298
+
299
+ Returns:
300
+ generation_id to use for storing this output
301
+ """
302
+ # Index 0 is always the primary generation
303
+ if output_index == 0:
304
+ return self.generation_id
305
+
306
+ # For batch outputs, ensure we have a batch_id
307
+ if self._batch_id is None:
308
+ self._batch_id = str(uuid4())
309
+ logger.debug(
310
+ "Created batch_id for multi-output generation",
311
+ batch_id=self._batch_id,
312
+ primary_generation_id=self.generation_id,
313
+ )
314
+
315
+ # Check if we've already created a generation for this index
316
+ batch_index = output_index - 1 # Adjust since index 0 is primary
317
+ if batch_index < len(self._batch_generations):
318
+ return self._batch_generations[batch_index]
319
+
320
+ # Create new batch generation record
321
+ async with get_async_session() as session:
322
+ batch_gen = await jobs_repo.create_batch_generation(
323
+ session,
324
+ tenant_id=UUID(self.tenant_id),
325
+ board_id=UUID(self.board_id),
326
+ user_id=UUID(self.user_id),
327
+ generator_name=self.generator_name,
328
+ artifact_type=self.artifact_type,
329
+ input_params=self.input_params,
330
+ batch_id=self._batch_id,
331
+ batch_index=output_index,
332
+ )
333
+ await session.commit()
334
+ batch_gen_id = str(batch_gen.id)
335
+
336
+ self._batch_generations.append(batch_gen_id)
337
+ logger.info(
338
+ "Created batch generation record",
339
+ batch_generation_id=batch_gen_id,
340
+ primary_generation_id=self.generation_id,
341
+ batch_id=self._batch_id,
342
+ batch_index=output_index,
343
+ )
344
+ return batch_gen_id
@@ -10,10 +10,11 @@
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
13
14
  "@radix-ui/react-navigation-menu": "^1.2.14",
14
15
  "@radix-ui/react-slot": "^1.2.3",
15
16
  "@tailwindcss/postcss": "^4.1.13",
16
- "@weirdfingers/boards": "^0.2.1",
17
+ "@weirdfingers/boards": "^0.4.0",
17
18
  "class-variance-authority": "^0.7.1",
18
19
  "clsx": "^2.0.0",
19
20
  "graphql": "^16.11.0",
@@ -1,8 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, useMemo, useEffect } from "react";
4
4
  import { Settings, ArrowUp, X } from "lucide-react";
5
5
  import Image from "next/image";
6
+ import {
7
+ ParsedGeneratorSchema,
8
+ parseGeneratorSchema,
9
+ } from "@weirdfingers/boards";
6
10
  import { GeneratorSelector, GeneratorInfo } from "./GeneratorSelector";
7
11
  import { ArtifactInputSlots } from "./ArtifactInputSlots";
8
12
 
@@ -13,12 +17,6 @@ interface Generation {
13
17
  thumbnailUrl?: string | null;
14
18
  }
15
19
 
16
- interface ArtifactSlot {
17
- name: string;
18
- type: string;
19
- required: boolean;
20
- }
21
-
22
20
  interface GenerationInputProps {
23
21
  generators: GeneratorInfo[];
24
22
  availableArtifacts: Generation[];
@@ -45,53 +43,42 @@ export function GenerationInput({
45
43
  >(new Map());
46
44
  const [attachedImage, setAttachedImage] = useState<Generation | null>(null);
47
45
  const [showSettings, setShowSettings] = useState(false);
46
+ const [settings, setSettings] = useState<Record<string, unknown>>({});
48
47
 
49
- // Parse input schema to determine if this generator needs artifact inputs
50
- const getArtifactSlots = (): ArtifactSlot[] => {
51
- if (!selectedGenerator) return [];
52
-
53
- const schema = selectedGenerator.inputSchema as {
54
- properties?: Record<
55
- string,
56
- {
57
- type?: string;
58
- anyOf?: Array<{ $ref?: string }>;
59
- description?: string;
60
- }
61
- >;
62
- required?: string[];
63
- };
64
-
65
- if (!schema.properties) return [];
66
-
67
- const slots: ArtifactSlot[] = [];
68
-
69
- for (const [key, value] of Object.entries(schema.properties)) {
70
- // Check if this property references an artifact type
71
- const isArtifact =
72
- value.anyOf?.some((ref) => ref.$ref?.includes("Artifact")) || false;
73
-
74
- if (isArtifact) {
75
- // Determine artifact type from the key or description
76
- let type = "video";
77
- if (key.toLowerCase().includes("audio")) type = "audio";
78
- if (key.toLowerCase().includes("video")) type = "video";
79
- if (key.toLowerCase().includes("image")) type = "image";
80
-
81
- slots.push({
82
- name: key,
83
- type,
84
- required: schema.required?.includes(key) || false,
85
- });
86
- }
48
+ // Parse input schema using the toolkit's schema parser
49
+ const parsedSchema = useMemo((): ParsedGeneratorSchema => {
50
+ if (!selectedGenerator) {
51
+ return { artifactSlots: [], promptField: null, settingsFields: [] };
87
52
  }
53
+ return parseGeneratorSchema(selectedGenerator.inputSchema);
54
+ }, [selectedGenerator]);
88
55
 
89
- return slots;
90
- };
56
+ const artifactSlots = useMemo(() => {
57
+ return parsedSchema.artifactSlots.map((slot) => ({
58
+ name: slot.fieldName,
59
+ type: slot.artifactType,
60
+ required: slot.required,
61
+ }));
62
+ }, [parsedSchema.artifactSlots]);
91
63
 
92
- const artifactSlots = getArtifactSlots();
93
64
  const needsArtifactInputs = artifactSlots.length > 0;
94
65
 
66
+ // Initialize settings with defaults when generator changes
67
+ const defaultSettings = useMemo(() => {
68
+ const defaults: Record<string, unknown> = {};
69
+ parsedSchema.settingsFields.forEach((field) => {
70
+ if (field.default !== undefined) {
71
+ defaults[field.fieldName] = field.default;
72
+ }
73
+ });
74
+ return defaults;
75
+ }, [parsedSchema.settingsFields]);
76
+
77
+ // Reset settings when generator changes or defaultSettings change
78
+ useEffect(() => {
79
+ setSettings(defaultSettings);
80
+ }, [selectedGenerator, defaultSettings]);
81
+
95
82
  const handleSubmit = () => {
96
83
  if (!selectedGenerator || !prompt.trim()) return;
97
84
 
@@ -99,13 +86,14 @@ export function GenerationInput({
99
86
  generatorName: selectedGenerator.name,
100
87
  prompt: prompt.trim(),
101
88
  artifacts: selectedArtifacts,
102
- settings: {},
89
+ settings,
103
90
  });
104
91
 
105
92
  // Reset form
106
93
  setPrompt("");
107
94
  setSelectedArtifacts(new Map());
108
95
  setAttachedImage(null);
96
+ setSettings(defaultSettings);
109
97
  };
110
98
 
111
99
  const handleSelectArtifact = (
@@ -241,9 +229,123 @@ export function GenerationInput({
241
229
  {/* Settings panel (collapsed by default) */}
242
230
  {showSettings && (
243
231
  <div className="px-4 py-4 border-t border-gray-200 bg-gray-50">
244
- <p className="text-sm text-gray-600">
245
- Generator-specific settings will appear here
246
- </p>
232
+ {parsedSchema.settingsFields.length === 0 ? (
233
+ <p className="text-sm text-gray-600">
234
+ No additional settings available for this generator
235
+ </p>
236
+ ) : (
237
+ <div>
238
+ <h3 className="text-sm font-medium text-gray-900 mb-4">
239
+ Generator Settings
240
+ </h3>
241
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
242
+ {parsedSchema.settingsFields.map((field) => (
243
+ <div key={field.fieldName} className="space-y-1.5">
244
+ <label
245
+ htmlFor={field.fieldName}
246
+ className="block text-sm font-medium text-gray-700"
247
+ >
248
+ {field.title}
249
+ </label>
250
+ {field.description && (
251
+ <p className="text-xs text-gray-500">{field.description}</p>
252
+ )}
253
+
254
+ {/* Slider control */}
255
+ {field.type === "slider" && (
256
+ <div className="space-y-1">
257
+ <input
258
+ id={field.fieldName}
259
+ type="range"
260
+ min={field.min}
261
+ max={field.max}
262
+ step={field.step || (field.isInteger ? 1 : 0.01)}
263
+ value={
264
+ (settings[field.fieldName] as number) ?? field.default
265
+ }
266
+ onChange={(e) =>
267
+ setSettings({
268
+ ...settings,
269
+ [field.fieldName]: field.isInteger
270
+ ? parseInt(e.target.value)
271
+ : parseFloat(e.target.value),
272
+ })
273
+ }
274
+ className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-orange-500"
275
+ />
276
+ <div className="flex justify-between text-xs text-gray-600">
277
+ <span>{field.min}</span>
278
+ <span className="font-medium">
279
+ {String(settings[field.fieldName] ?? field.default ?? field.min)}
280
+ </span>
281
+ <span>{field.max}</span>
282
+ </div>
283
+ </div>
284
+ )}
285
+
286
+ {/* Dropdown control */}
287
+ {field.type === "dropdown" && (
288
+ <select
289
+ id={field.fieldName}
290
+ value={(settings[field.fieldName] as string) ?? field.default}
291
+ onChange={(e) =>
292
+ setSettings({
293
+ ...settings,
294
+ [field.fieldName]: e.target.value,
295
+ })
296
+ }
297
+ className="block w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
298
+ >
299
+ {field.options.map((option) => (
300
+ <option key={option} value={option}>
301
+ {option}
302
+ </option>
303
+ ))}
304
+ </select>
305
+ )}
306
+
307
+ {/* Number input control */}
308
+ {field.type === "number" && (
309
+ <input
310
+ id={field.fieldName}
311
+ type="number"
312
+ min={field.min}
313
+ max={field.max}
314
+ step={field.isInteger ? 1 : "any"}
315
+ value={(settings[field.fieldName] as number) ?? field.default}
316
+ onChange={(e) =>
317
+ setSettings({
318
+ ...settings,
319
+ [field.fieldName]: field.isInteger
320
+ ? parseInt(e.target.value)
321
+ : parseFloat(e.target.value),
322
+ })
323
+ }
324
+ className="block w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
325
+ />
326
+ )}
327
+
328
+ {/* Text input control */}
329
+ {field.type === "text" && (
330
+ <input
331
+ id={field.fieldName}
332
+ type="text"
333
+ value={(settings[field.fieldName] as string) ?? field.default ?? ""}
334
+ onChange={(e) =>
335
+ setSettings({
336
+ ...settings,
337
+ [field.fieldName]: e.target.value,
338
+ })
339
+ }
340
+ pattern={field.pattern}
341
+ className="block w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
342
+ />
343
+ )}
344
+ </div>
345
+ ))}
346
+ </div>
347
+ </div>
348
+ )}
247
349
  </div>
248
350
  )}
249
351
  </div>