pxengine 0.1.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.
- package/README.md +175 -0
- package/config/tailwind-preset.js +106 -0
- package/dist/index.d.mts +1259 -0
- package/dist/index.d.ts +1259 -0
- package/dist/index.js +5175 -0
- package/dist/index.mjs +4929 -0
- package/package.json +94 -0
- package/src/atoms/AccordionAtom.tsx +44 -0
- package/src/atoms/AlertAtom.tsx +46 -0
- package/src/atoms/AlertDialogAtom.tsx +66 -0
- package/src/atoms/AspectRatioAtom.tsx +27 -0
- package/src/atoms/AvatarAtom.tsx +20 -0
- package/src/atoms/BadgeAtom.tsx +25 -0
- package/src/atoms/BreadcrumbAtom.tsx +36 -0
- package/src/atoms/ButtonAtom.tsx +63 -0
- package/src/atoms/CalendarAtom.tsx +24 -0
- package/src/atoms/CardAtom.tsx +64 -0
- package/src/atoms/CarouselAtom.tsx +40 -0
- package/src/atoms/CollapsibleAtom.tsx +44 -0
- package/src/atoms/CommandAtom.tsx +46 -0
- package/src/atoms/DialogAtom.tsx +68 -0
- package/src/atoms/InputAtom.tsx +162 -0
- package/src/atoms/LayoutAtom.tsx +43 -0
- package/src/atoms/PaginationAtom.tsx +49 -0
- package/src/atoms/PopoverAtom.tsx +40 -0
- package/src/atoms/ProgressAtom.tsx +15 -0
- package/src/atoms/ScrollAreaAtom.tsx +31 -0
- package/src/atoms/SeparatorAtom.tsx +16 -0
- package/src/atoms/SheetAtom.tsx +72 -0
- package/src/atoms/SkeletonAtom.tsx +22 -0
- package/src/atoms/SpinnerAtom.tsx +26 -0
- package/src/atoms/TableAtom.tsx +58 -0
- package/src/atoms/TabsAtom.tsx +40 -0
- package/src/atoms/TextAtom.tsx +35 -0
- package/src/atoms/TooltipAtom.tsx +39 -0
- package/src/atoms/index.ts +28 -0
- package/src/components/index.ts +178 -0
- package/src/components/ui/accordion.tsx +56 -0
- package/src/components/ui/alert-dialog.tsx +139 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/aspect-ratio.tsx +5 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/breadcrumb.tsx +115 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/calendar.tsx +213 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/carousel.tsx +260 -0
- package/src/components/ui/chart.tsx +367 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/command.tsx +153 -0
- package/src/components/ui/context-menu.tsx +198 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/drawer.tsx +116 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +244 -0
- package/src/components/ui/form.tsx +176 -0
- package/src/components/ui/hover-card.tsx +27 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input-otp.tsx +69 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/item.tsx +193 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/menubar.tsx +254 -0
- package/src/components/ui/navigation-menu.tsx +128 -0
- package/src/components/ui/pagination.tsx +117 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/radio-group.tsx +42 -0
- package/src/components/ui/resizable.tsx +45 -0
- package/src/components/ui/scroll-area.tsx +46 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +771 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/slider.tsx +26 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/toggle-group.tsx +61 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/index.ts +24 -0
- package/src/lib/countries.ts +203 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/utils.ts +15 -0
- package/src/lib/validators/index.ts +1 -0
- package/src/lib/validators/theme.ts +148 -0
- package/src/molecules/creator-discovery/CampaignSeedCard/CampaignSeedCard.tsx +123 -0
- package/src/molecules/creator-discovery/CampaignSeedCard/CampaignSeedCard.types.ts +13 -0
- package/src/molecules/creator-discovery/CampaignSeedCard/index.ts +2 -0
- package/src/molecules/creator-discovery/MCQCard/MCQCard.tsx +165 -0
- package/src/molecules/creator-discovery/MCQCard/MCQCard.types.ts +71 -0
- package/src/molecules/creator-discovery/MCQCard/index.ts +2 -0
- package/src/molecules/creator-discovery/SearchSpecCard/CustomFieldRenderers.tsx +334 -0
- package/src/molecules/creator-discovery/SearchSpecCard/SearchSpecCard.tsx +111 -0
- package/src/molecules/creator-discovery/SearchSpecCard/SearchSpecCard.types.ts +18 -0
- package/src/molecules/creator-discovery/SearchSpecCard/index.ts +3 -0
- package/src/molecules/creator-discovery/index.ts +3 -0
- package/src/molecules/generic/ActionButton/ActionButton.tsx +137 -0
- package/src/molecules/generic/ActionButton/ActionButton.types.ts +68 -0
- package/src/molecules/generic/ActionButton/index.ts +2 -0
- package/src/molecules/generic/EditableField/EditableField.tsx +229 -0
- package/src/molecules/generic/EditableField/EditableField.types.ts +73 -0
- package/src/molecules/generic/EditableField/index.ts +2 -0
- package/src/molecules/generic/FormCard/FormCard.tsx +136 -0
- package/src/molecules/generic/FormCard/FormCard.types.ts +93 -0
- package/src/molecules/generic/FormCard/index.ts +2 -0
- package/src/molecules/generic/index.ts +3 -0
- package/src/molecules/index.ts +2 -0
- package/src/render/PXEngineRenderer.tsx +272 -0
- package/src/render/index.ts +1 -0
- package/src/styles/globals.css +146 -0
- package/src/types/atoms.ts +294 -0
- package/src/types/common.ts +116 -0
- package/src/types/index.ts +3 -0
- package/src/types/molecules.ts +54 -0
- package/src/types/schema.ts +12 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ButtonVariant, ButtonSize } from "@/types/common";
|
|
2
|
+
|
|
3
|
+
export interface ActionButtonProps {
|
|
4
|
+
/**
|
|
5
|
+
* Unique identifier
|
|
6
|
+
*/
|
|
7
|
+
id?: string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main button label
|
|
11
|
+
*/
|
|
12
|
+
label: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Secondary label (e.g. for sub-actions or state changes)
|
|
16
|
+
*/
|
|
17
|
+
secondaryLabel?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Countdown in seconds. If provided, the button will show a timer.
|
|
21
|
+
*/
|
|
22
|
+
countdown?: number;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the auto-proceed is paused
|
|
26
|
+
*/
|
|
27
|
+
isPaused?: boolean;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Triggered when the user pauses/resumes the timer
|
|
31
|
+
*/
|
|
32
|
+
onPause?: () => void;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Triggered when the user clicks the button or timer expires
|
|
36
|
+
*/
|
|
37
|
+
onProceed: () => void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Button variant (from shadcn)
|
|
41
|
+
*/
|
|
42
|
+
variant?: ButtonVariant;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Button size (from shadcn)
|
|
46
|
+
*/
|
|
47
|
+
size?: ButtonSize;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether the button is disabled
|
|
51
|
+
*/
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Loading state
|
|
56
|
+
*/
|
|
57
|
+
isLoading?: boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Custom className
|
|
61
|
+
*/
|
|
62
|
+
className?: string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether to show the countdown visually
|
|
66
|
+
*/
|
|
67
|
+
showCountdown?: boolean;
|
|
68
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { EditableFieldProps } from "./EditableField.types";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Input,
|
|
7
|
+
Label,
|
|
8
|
+
Textarea,
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
Slider,
|
|
15
|
+
} from "@/components";
|
|
16
|
+
import { Check, X, Pencil, Loader2 } from "lucide-react";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* EditableField
|
|
20
|
+
*
|
|
21
|
+
* A generic field that toggles between display and edit modes.
|
|
22
|
+
* Supports various input types and custom rendering.
|
|
23
|
+
*/
|
|
24
|
+
export const EditableField = React.memo<EditableFieldProps>(
|
|
25
|
+
({
|
|
26
|
+
label,
|
|
27
|
+
value,
|
|
28
|
+
type,
|
|
29
|
+
isEditing: isEditingProp,
|
|
30
|
+
onEdit,
|
|
31
|
+
onSave,
|
|
32
|
+
onCancel,
|
|
33
|
+
isSaving = false,
|
|
34
|
+
isChanged = false,
|
|
35
|
+
config = {},
|
|
36
|
+
className,
|
|
37
|
+
renderDisplay,
|
|
38
|
+
renderEdit,
|
|
39
|
+
}) => {
|
|
40
|
+
const [localValue, setLocalValue] = useState(value);
|
|
41
|
+
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
|
42
|
+
|
|
43
|
+
// Sync local value when external value changes or editing starts
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
setLocalValue(value);
|
|
46
|
+
}, [value, isEditingProp]);
|
|
47
|
+
|
|
48
|
+
// Focus input when editing starts
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (isEditingProp) {
|
|
51
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
52
|
+
}
|
|
53
|
+
}, [isEditingProp]);
|
|
54
|
+
|
|
55
|
+
const handleSave = () => {
|
|
56
|
+
onSave?.(localValue);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
60
|
+
if (e.key === "Enter" && !e.shiftKey && type !== "textarea") {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
handleSave();
|
|
63
|
+
} else if (e.key === "Escape") {
|
|
64
|
+
onCancel?.();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const renderInput = () => {
|
|
69
|
+
if (renderEdit) {
|
|
70
|
+
return renderEdit(localValue, setLocalValue);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
switch (type) {
|
|
74
|
+
case "textarea":
|
|
75
|
+
return (
|
|
76
|
+
<Textarea
|
|
77
|
+
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
|
78
|
+
value={localValue || ""}
|
|
79
|
+
onChange={(e) => setLocalValue(e.target.value)}
|
|
80
|
+
onKeyDown={handleKeyDown}
|
|
81
|
+
placeholder={config.placeholder}
|
|
82
|
+
rows={config.rows || 3}
|
|
83
|
+
className="min-h-[80px] resize-none"
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
case "select":
|
|
87
|
+
return (
|
|
88
|
+
<Select
|
|
89
|
+
value={localValue?.toString()}
|
|
90
|
+
onValueChange={(val) => setLocalValue(val)}
|
|
91
|
+
>
|
|
92
|
+
<SelectTrigger className="w-full">
|
|
93
|
+
<SelectValue
|
|
94
|
+
placeholder={config.placeholder || "Select an option"}
|
|
95
|
+
/>
|
|
96
|
+
</SelectTrigger>
|
|
97
|
+
<SelectContent>
|
|
98
|
+
{config.options?.map((opt: any) => {
|
|
99
|
+
const label = typeof opt === "string" ? opt : opt.label;
|
|
100
|
+
const val = typeof opt === "string" ? opt : opt.value;
|
|
101
|
+
return (
|
|
102
|
+
<SelectItem key={val} value={val}>
|
|
103
|
+
{label}
|
|
104
|
+
</SelectItem>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</SelectContent>
|
|
108
|
+
</Select>
|
|
109
|
+
);
|
|
110
|
+
case "slider":
|
|
111
|
+
return (
|
|
112
|
+
<div className="pt-6 pb-2 px-2">
|
|
113
|
+
<Slider
|
|
114
|
+
defaultValue={[localValue?.min || 0, localValue?.max || 100]}
|
|
115
|
+
max={config.sliderConfig?.max || 100}
|
|
116
|
+
min={config.sliderConfig?.min || 0}
|
|
117
|
+
step={config.sliderConfig?.step || 1}
|
|
118
|
+
onValueChange={([min, max]) => setLocalValue({ min, max })}
|
|
119
|
+
/>
|
|
120
|
+
{config.sliderConfig?.formatValue && (
|
|
121
|
+
<div className="mt-2 text-xs text-muted-foreground text-center">
|
|
122
|
+
{config.sliderConfig.formatValue(localValue)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
case "number":
|
|
128
|
+
return (
|
|
129
|
+
<Input
|
|
130
|
+
ref={inputRef as React.RefObject<HTMLInputElement>}
|
|
131
|
+
type="number"
|
|
132
|
+
value={localValue || ""}
|
|
133
|
+
onChange={(e) => setLocalValue(e.target.value)}
|
|
134
|
+
onKeyDown={handleKeyDown}
|
|
135
|
+
min={config.numberConfig?.min}
|
|
136
|
+
max={config.numberConfig?.max}
|
|
137
|
+
step={config.numberConfig?.step}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
default:
|
|
141
|
+
return (
|
|
142
|
+
<Input
|
|
143
|
+
ref={inputRef as React.RefObject<HTMLInputElement>}
|
|
144
|
+
type="text"
|
|
145
|
+
value={localValue || ""}
|
|
146
|
+
onChange={(e) => setLocalValue(e.target.value)}
|
|
147
|
+
onKeyDown={handleKeyDown}
|
|
148
|
+
placeholder={config.placeholder}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const formattedValue = () => {
|
|
155
|
+
if (renderDisplay) return renderDisplay(value);
|
|
156
|
+
|
|
157
|
+
if (type === "slider") {
|
|
158
|
+
return config.sliderConfig?.formatValue
|
|
159
|
+
? config.sliderConfig.formatValue(value)
|
|
160
|
+
: `${value?.min} - ${value?.max}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!value)
|
|
164
|
+
return <span className="text-muted-foreground italic">Not set</span>;
|
|
165
|
+
|
|
166
|
+
return value.toString();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className={cn("group flex flex-col gap-1.5 py-2", className)}>
|
|
171
|
+
<div className="flex items-center justify-between">
|
|
172
|
+
<Label className="text-xs font-medium text-gray500 uppercase tracking-tight">
|
|
173
|
+
{label}
|
|
174
|
+
</Label>
|
|
175
|
+
{isChanged && !isEditingProp && (
|
|
176
|
+
<div
|
|
177
|
+
className="w-1.5 h-1.5 rounded-full bg-amber-500"
|
|
178
|
+
title="Unsaved changes"
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{isEditingProp ? (
|
|
184
|
+
<div className="flex flex-col gap-2">
|
|
185
|
+
{renderInput()}
|
|
186
|
+
<div className="flex items-center justify-end gap-2">
|
|
187
|
+
<Button
|
|
188
|
+
size="icon"
|
|
189
|
+
variant="outline"
|
|
190
|
+
className="h-8 w-8 text-destructive border-destructive/20 hover:bg-destructive/10"
|
|
191
|
+
onClick={onCancel}
|
|
192
|
+
disabled={isSaving}
|
|
193
|
+
>
|
|
194
|
+
<X className="h-4 w-4" />
|
|
195
|
+
</Button>
|
|
196
|
+
<Button
|
|
197
|
+
size="icon"
|
|
198
|
+
className="h-8 w-8 bg-purple500 hover:bg-purple600 text-white"
|
|
199
|
+
onClick={handleSave}
|
|
200
|
+
disabled={isSaving}
|
|
201
|
+
>
|
|
202
|
+
{isSaving ? (
|
|
203
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
204
|
+
) : (
|
|
205
|
+
<Check className="h-4 w-4" />
|
|
206
|
+
)}
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
) : (
|
|
211
|
+
<div
|
|
212
|
+
className={cn(
|
|
213
|
+
"relative flex items-center justify-between rounded-md px-2 py-1.5 transition-all",
|
|
214
|
+
"hover:bg-gray-100/50 cursor-pointer border border-transparent hover:border-gray-200",
|
|
215
|
+
)}
|
|
216
|
+
onClick={onEdit}
|
|
217
|
+
>
|
|
218
|
+
<div className="text-sm text-gray-900 font-medium truncate flex-1 leading-relaxed">
|
|
219
|
+
{formattedValue()}
|
|
220
|
+
</div>
|
|
221
|
+
<Pencil className="h-3.5 w-3.5 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
EditableField.displayName = "EditableField";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { FieldType, FieldConfig } from "@/types/common";
|
|
2
|
+
|
|
3
|
+
export interface EditableFieldProps {
|
|
4
|
+
/**
|
|
5
|
+
* Unique identifier
|
|
6
|
+
*/
|
|
7
|
+
id?: string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Field label
|
|
11
|
+
*/
|
|
12
|
+
label: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Field value
|
|
16
|
+
*/
|
|
17
|
+
value: any;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Input type (text, textarea, number, slider, etc.)
|
|
21
|
+
*/
|
|
22
|
+
type: FieldType;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Current editing state
|
|
26
|
+
*/
|
|
27
|
+
isEditing?: boolean;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Triggered when the user wants to start editing
|
|
31
|
+
*/
|
|
32
|
+
onEdit?: () => void;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Triggered when the user saves the new value
|
|
36
|
+
*/
|
|
37
|
+
onSave?: (newValue: any) => void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Triggered when the user cancels editing
|
|
41
|
+
*/
|
|
42
|
+
onCancel?: () => void;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Loading state during save
|
|
46
|
+
*/
|
|
47
|
+
isSaving?: boolean;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Indicates if the value has been changed but not saved
|
|
51
|
+
*/
|
|
52
|
+
isChanged?: boolean;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Configuration for the specific field type
|
|
56
|
+
*/
|
|
57
|
+
config?: Partial<FieldConfig>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Custom className for the container
|
|
61
|
+
*/
|
|
62
|
+
className?: string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Custom renderer for the display state
|
|
66
|
+
*/
|
|
67
|
+
renderDisplay?: (value: any) => React.ReactNode;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Custom renderer for the edit state
|
|
71
|
+
*/
|
|
72
|
+
renderEdit?: (value: any, onChange: (v: any) => void) => React.ReactNode;
|
|
73
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FormCardProps } from "./FormCard.types";
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
CardFooter,
|
|
9
|
+
} from "@/components";
|
|
10
|
+
import { EditableField } from "../EditableField";
|
|
11
|
+
import { ActionButton } from "../ActionButton";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import { Copy } from "lucide-react";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* FormCard
|
|
17
|
+
*
|
|
18
|
+
* A high-level molecule that assembles EditableFields into a cohesive card unit.
|
|
19
|
+
* Features:
|
|
20
|
+
* - Dotted vertical timeline visual
|
|
21
|
+
* - Copy to clipboard functionality
|
|
22
|
+
* - Integrated ActionButton with countdown
|
|
23
|
+
* - Responsive layout
|
|
24
|
+
*/
|
|
25
|
+
export const FormCard = React.memo<FormCardProps>(
|
|
26
|
+
({
|
|
27
|
+
title,
|
|
28
|
+
fields,
|
|
29
|
+
data,
|
|
30
|
+
editingFields = {},
|
|
31
|
+
changedFields = {},
|
|
32
|
+
savingFields = {},
|
|
33
|
+
onFieldEdit,
|
|
34
|
+
onFieldSave,
|
|
35
|
+
onFieldCancel,
|
|
36
|
+
showTimeline = true,
|
|
37
|
+
proceedLabel,
|
|
38
|
+
countdown,
|
|
39
|
+
isPaused = false,
|
|
40
|
+
onPause,
|
|
41
|
+
onProceed,
|
|
42
|
+
className,
|
|
43
|
+
footer,
|
|
44
|
+
}) => {
|
|
45
|
+
const handleCopyAll = () => {
|
|
46
|
+
const text = fields
|
|
47
|
+
.map(
|
|
48
|
+
(f) =>
|
|
49
|
+
`${f.label}: ${typeof data[f.key] === "object" ? JSON.stringify(data[f.key]) : data[f.key]}`,
|
|
50
|
+
)
|
|
51
|
+
.join("\n");
|
|
52
|
+
navigator.clipboard.writeText(text);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Card
|
|
57
|
+
className={cn(
|
|
58
|
+
"w-full rounded-[24px] border border-gray200 bg-white shadow-sm overflow-hidden",
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
|
63
|
+
<CardTitle className="text-lg font-bold text-gray-900 tracking-tight">
|
|
64
|
+
{title}
|
|
65
|
+
</CardTitle>
|
|
66
|
+
<button
|
|
67
|
+
onClick={handleCopyAll}
|
|
68
|
+
className="p-1.5 rounded-md hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors"
|
|
69
|
+
title="Copy all details"
|
|
70
|
+
>
|
|
71
|
+
<Copy className="h-4 w-4" />
|
|
72
|
+
</button>
|
|
73
|
+
</CardHeader>
|
|
74
|
+
|
|
75
|
+
<CardContent className="pt-2 pb-6">
|
|
76
|
+
<div className="relative">
|
|
77
|
+
{/* Vertical Timeline Line */}
|
|
78
|
+
{showTimeline && (
|
|
79
|
+
<div className="absolute left-[7px] top-2 bottom-6 w-0.5 border-l-2 border-dotted border-gray200 pointer-events-none" />
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
<div className="space-y-4">
|
|
83
|
+
{fields.map((field) => (
|
|
84
|
+
<div key={field.key} className="relative pl-6">
|
|
85
|
+
{/* Timeline Dot */}
|
|
86
|
+
{showTimeline && (
|
|
87
|
+
<div
|
|
88
|
+
className={cn(
|
|
89
|
+
"absolute left-0 top-[18px] w-[14px] h-[14px] -translate-x-1/2 rounded-full border-2 bg-white z-10",
|
|
90
|
+
changedFields[field.key]
|
|
91
|
+
? "border-amber-500"
|
|
92
|
+
: "border-gray-200",
|
|
93
|
+
)}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
<EditableField
|
|
98
|
+
label={field.label}
|
|
99
|
+
value={data[field.key]}
|
|
100
|
+
type={field.type}
|
|
101
|
+
config={field}
|
|
102
|
+
isEditing={editingFields[field.key]}
|
|
103
|
+
isChanged={changedFields[field.key]}
|
|
104
|
+
isSaving={savingFields[field.key]}
|
|
105
|
+
onEdit={() => onFieldEdit?.(field.key)}
|
|
106
|
+
onSave={(val) => onFieldSave?.(field.key, val)}
|
|
107
|
+
onCancel={() => onFieldCancel?.(field.key)}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</CardContent>
|
|
114
|
+
|
|
115
|
+
{(onProceed || footer) && (
|
|
116
|
+
<CardFooter className="flex flex-col gap-4 pt-0 border-t border-gray-100/50 bg-gray-50/30 p-6">
|
|
117
|
+
{onProceed && proceedLabel && (
|
|
118
|
+
<div className="w-full flex justify-center">
|
|
119
|
+
<ActionButton
|
|
120
|
+
label={proceedLabel}
|
|
121
|
+
countdown={countdown}
|
|
122
|
+
isPaused={isPaused}
|
|
123
|
+
onPause={onPause}
|
|
124
|
+
onProceed={onProceed}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
{footer}
|
|
129
|
+
</CardFooter>
|
|
130
|
+
)}
|
|
131
|
+
</Card>
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
FormCard.displayName = "FormCard";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { FieldConfig } from "@/types/common";
|
|
2
|
+
|
|
3
|
+
export interface FormCardProps {
|
|
4
|
+
/**
|
|
5
|
+
* Unique identifier
|
|
6
|
+
*/
|
|
7
|
+
id?: string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Title of the form card
|
|
11
|
+
*/
|
|
12
|
+
title: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Map of fields to render
|
|
16
|
+
*/
|
|
17
|
+
fields: FieldConfig[];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Initial data for the form
|
|
21
|
+
*/
|
|
22
|
+
data: Record<string, any>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Current editing states for each field
|
|
26
|
+
*/
|
|
27
|
+
editingFields?: Record<string, boolean>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fields that have been modified
|
|
31
|
+
*/
|
|
32
|
+
changedFields?: Record<string, boolean>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fields currently being saved
|
|
36
|
+
*/
|
|
37
|
+
savingFields?: Record<string, boolean>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Triggered when a field starts editing
|
|
41
|
+
*/
|
|
42
|
+
onFieldEdit?: (key: string) => void;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Triggered when a field value is saved
|
|
46
|
+
*/
|
|
47
|
+
onFieldSave?: (key: string, newValue: any) => void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Triggered when a field edit is cancelled
|
|
51
|
+
*/
|
|
52
|
+
onFieldCancel?: (key: string) => void;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Show a dotted timeline visual on the left
|
|
56
|
+
*/
|
|
57
|
+
showTimeline?: boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Label for the "Proceed" button
|
|
61
|
+
*/
|
|
62
|
+
proceedLabel?: string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Countdown for auto-proceed
|
|
66
|
+
*/
|
|
67
|
+
countdown?: number;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether the timer is paused
|
|
71
|
+
*/
|
|
72
|
+
isPaused?: boolean;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pause/Resume handler
|
|
76
|
+
*/
|
|
77
|
+
onPause?: () => void;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Proceed handler
|
|
81
|
+
*/
|
|
82
|
+
onProceed?: () => void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Custom className
|
|
86
|
+
*/
|
|
87
|
+
className?: string;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Footer content (optional)
|
|
91
|
+
*/
|
|
92
|
+
footer?: React.ReactNode;
|
|
93
|
+
}
|