@magnet-cms/plugin-playground 2.0.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.
@@ -0,0 +1,292 @@
1
+ import { useAdmin } from '@magnet-cms/admin'
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ Input,
11
+ Label,
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from '@magnet-cms/ui/components'
18
+ import { cn } from '@magnet-cms/ui/lib'
19
+ import { useState } from 'react'
20
+ import type { RelationConfig } from '../types/builder.types'
21
+
22
+ interface RelationConfigModalProps {
23
+ open: boolean
24
+ onOpenChange: (open: boolean) => void
25
+ currentSchema: string
26
+ relationConfig: RelationConfig | undefined
27
+ onSave: (config: RelationConfig) => void
28
+ }
29
+
30
+ type RelationType = 'oneToOne' | 'oneToMany' | 'manyToOne' | 'manyToMany'
31
+
32
+ interface RelationTypeOption {
33
+ value: RelationType
34
+ label: string
35
+ description: string
36
+ diagram: {
37
+ leftMultiple: boolean
38
+ rightMultiple: boolean
39
+ }
40
+ }
41
+
42
+ const RELATION_TYPE_OPTIONS: RelationTypeOption[] = [
43
+ {
44
+ value: 'oneToOne',
45
+ label: 'One to One',
46
+ description: 'Each record relates to exactly one other record',
47
+ diagram: { leftMultiple: false, rightMultiple: false },
48
+ },
49
+ {
50
+ value: 'oneToMany',
51
+ label: 'One to Many',
52
+ description: 'One record can relate to many other records',
53
+ diagram: { leftMultiple: false, rightMultiple: true },
54
+ },
55
+ {
56
+ value: 'manyToOne',
57
+ label: 'Many to One',
58
+ description: 'Many records can relate to one other record',
59
+ diagram: { leftMultiple: true, rightMultiple: false },
60
+ },
61
+ {
62
+ value: 'manyToMany',
63
+ label: 'Many to Many',
64
+ description: 'Many records can relate to many other records',
65
+ diagram: { leftMultiple: true, rightMultiple: true },
66
+ },
67
+ ]
68
+
69
+ function RelationDiagram({
70
+ leftLabel,
71
+ rightLabel,
72
+ leftMultiple,
73
+ rightMultiple,
74
+ small = false,
75
+ }: {
76
+ leftLabel: string
77
+ rightLabel: string
78
+ leftMultiple: boolean
79
+ rightMultiple: boolean
80
+ small?: boolean
81
+ }) {
82
+ return (
83
+ <div className="flex items-center justify-center gap-2">
84
+ {/* Left side */}
85
+ <div className="flex items-center gap-1">
86
+ {leftMultiple && (
87
+ <div
88
+ className={cn(
89
+ 'bg-background border rounded font-medium shadow-sm opacity-50 -mr-1',
90
+ small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs',
91
+ )}
92
+ >
93
+ {leftLabel}
94
+ </div>
95
+ )}
96
+ <div
97
+ className={cn(
98
+ 'bg-background border rounded font-medium shadow-sm relative z-10',
99
+ small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs',
100
+ )}
101
+ >
102
+ {leftLabel}
103
+ </div>
104
+ </div>
105
+
106
+ {/* Connection line */}
107
+ <div className="flex items-center">
108
+ <div
109
+ className={cn(
110
+ 'bg-muted-foreground/60 rounded-full',
111
+ small ? 'w-1.5 h-1.5' : 'w-2 h-2',
112
+ )}
113
+ />
114
+ <div
115
+ className={cn('h-px bg-muted-foreground/60', small ? 'w-6' : 'w-10')}
116
+ />
117
+ <div
118
+ className={cn(
119
+ 'border-r-2 border-t-2 border-b-2 border-muted-foreground/60 rotate-45',
120
+ small ? 'w-1.5 h-1.5 -ml-1' : 'w-2 h-2 -ml-1.5',
121
+ )}
122
+ />
123
+ </div>
124
+
125
+ {/* Right side */}
126
+ <div className="flex items-center gap-1">
127
+ <div
128
+ className={cn(
129
+ 'bg-background border rounded font-medium shadow-sm relative z-10',
130
+ small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs',
131
+ )}
132
+ >
133
+ {rightLabel}
134
+ </div>
135
+ {rightMultiple && (
136
+ <div
137
+ className={cn(
138
+ 'bg-background border rounded font-medium shadow-sm opacity-50 -ml-1',
139
+ small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs',
140
+ )}
141
+ >
142
+ {rightLabel}
143
+ </div>
144
+ )}
145
+ </div>
146
+ </div>
147
+ )
148
+ }
149
+
150
+ export function RelationConfigModal({
151
+ open,
152
+ onOpenChange,
153
+ currentSchema,
154
+ relationConfig,
155
+ onSave,
156
+ }: RelationConfigModalProps) {
157
+ const { schemas } = useAdmin()
158
+ const [config, setConfig] = useState<RelationConfig>({
159
+ targetSchema: relationConfig?.targetSchema || '',
160
+ relationType: relationConfig?.relationType || 'manyToOne',
161
+ inverseSide: relationConfig?.inverseSide,
162
+ })
163
+
164
+ const handleSave = () => {
165
+ onSave(config)
166
+ onOpenChange(false)
167
+ }
168
+
169
+ const selectedTypeOption = RELATION_TYPE_OPTIONS.find(
170
+ (t) => t.value === config.relationType,
171
+ )
172
+
173
+ return (
174
+ <Dialog open={open} onOpenChange={onOpenChange}>
175
+ <DialogContent className="sm:max-w-lg">
176
+ <DialogHeader>
177
+ <DialogTitle>Configure Relation</DialogTitle>
178
+ <DialogDescription>
179
+ Define how this field relates to another schema
180
+ </DialogDescription>
181
+ </DialogHeader>
182
+
183
+ <div className="space-y-6 py-4">
184
+ {/* Relation Type Picker */}
185
+ <div className="space-y-3">
186
+ <Label className="text-sm font-medium">Relation Type</Label>
187
+ <div className="grid grid-cols-2 gap-3">
188
+ {RELATION_TYPE_OPTIONS.map((option) => (
189
+ <button
190
+ key={option.value}
191
+ type="button"
192
+ onClick={() =>
193
+ setConfig((c) => ({ ...c, relationType: option.value }))
194
+ }
195
+ className={cn(
196
+ 'p-3 rounded-lg border text-left transition-all',
197
+ config.relationType === option.value
198
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
199
+ : 'border-border hover:border-muted-foreground/50 hover:bg-muted/50',
200
+ )}
201
+ >
202
+ <div className="mb-2">
203
+ <RelationDiagram
204
+ leftLabel={currentSchema || 'A'}
205
+ rightLabel={config.targetSchema || 'B'}
206
+ leftMultiple={option.diagram.leftMultiple}
207
+ rightMultiple={option.diagram.rightMultiple}
208
+ small
209
+ />
210
+ </div>
211
+ <p className="text-sm font-medium">{option.label}</p>
212
+ <p className="text-xs text-muted-foreground mt-0.5">
213
+ {option.description}
214
+ </p>
215
+ </button>
216
+ ))}
217
+ </div>
218
+ </div>
219
+
220
+ {/* Preview */}
221
+ {selectedTypeOption && (
222
+ <div className="p-4 bg-muted/30 rounded-lg border">
223
+ <p className="text-xs text-muted-foreground mb-3 text-center">
224
+ Preview
225
+ </p>
226
+ <RelationDiagram
227
+ leftLabel={currentSchema || 'Current'}
228
+ rightLabel={config.targetSchema || 'Target'}
229
+ leftMultiple={selectedTypeOption.diagram.leftMultiple}
230
+ rightMultiple={selectedTypeOption.diagram.rightMultiple}
231
+ />
232
+ </div>
233
+ )}
234
+
235
+ {/* Target Schema */}
236
+ <div className="space-y-2">
237
+ <Label className="text-sm font-medium">Related Collection</Label>
238
+ <Select
239
+ value={config.targetSchema}
240
+ onValueChange={(value) =>
241
+ setConfig((c) => ({ ...c, targetSchema: value }))
242
+ }
243
+ >
244
+ <SelectTrigger>
245
+ <SelectValue placeholder="Select a schema..." />
246
+ </SelectTrigger>
247
+ <SelectContent>
248
+ {schemas?.map((schema) => (
249
+ <SelectItem key={schema} value={schema}>
250
+ {schema}
251
+ </SelectItem>
252
+ ))}
253
+ </SelectContent>
254
+ </Select>
255
+ <p className="text-xs text-muted-foreground">
256
+ The schema this field will reference
257
+ </p>
258
+ </div>
259
+
260
+ {/* Inverse Field Name */}
261
+ <div className="space-y-2">
262
+ <Label className="text-sm font-medium">
263
+ Inverse Field Name{' '}
264
+ <span className="text-muted-foreground font-normal">
265
+ (optional)
266
+ </span>
267
+ </Label>
268
+ <Input
269
+ value={config.inverseSide || ''}
270
+ onChange={(e) =>
271
+ setConfig((c) => ({ ...c, inverseSide: e.target.value }))
272
+ }
273
+ placeholder={`e.g., ${currentSchema?.toLowerCase() || 'items'}`}
274
+ />
275
+ <p className="text-xs text-muted-foreground">
276
+ Field name on the related schema for bidirectional access
277
+ </p>
278
+ </div>
279
+ </div>
280
+
281
+ <DialogFooter>
282
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
283
+ Cancel
284
+ </Button>
285
+ <Button onClick={handleSave} disabled={!config.targetSchema}>
286
+ Save Configuration
287
+ </Button>
288
+ </DialogFooter>
289
+ </DialogContent>
290
+ </Dialog>
291
+ )
292
+ }
@@ -0,0 +1,76 @@
1
+ import { useAdmin } from '@magnet-cms/admin'
2
+ import { Button, Spinner } from '@magnet-cms/ui/components'
3
+ import { names } from '@magnet-cms/utils'
4
+ import { Database, Plus } from 'lucide-react'
5
+ import { useNavigate, useParams } from 'react-router-dom'
6
+
7
+ export function SchemaList() {
8
+ const navigate = useNavigate()
9
+ const { schemaName } = useParams<{ schemaName: string }>()
10
+ const { schemas, isLoading, error } = useAdmin()
11
+
12
+ if (isLoading) {
13
+ return (
14
+ <div className="flex justify-center items-center h-32">
15
+ <Spinner className="h-5 w-5" />
16
+ </div>
17
+ )
18
+ }
19
+
20
+ if (error) {
21
+ return <div className="p-3 text-xs text-red-600">Error loading schemas</div>
22
+ }
23
+
24
+ const isNewSchema = schemaName === 'new'
25
+
26
+ return (
27
+ <div className="flex flex-col h-full">
28
+ {/* Header */}
29
+ <div className="p-3 border-b">
30
+ <Button
31
+ size="sm"
32
+ className="w-full"
33
+ variant={isNewSchema ? 'default' : 'outline'}
34
+ onClick={() => navigate('/playground/new')}
35
+ >
36
+ <Plus className="h-3.5 w-3.5 mr-1.5" />
37
+ New Schema
38
+ </Button>
39
+ </div>
40
+
41
+ {/* Schema List */}
42
+ <div className="flex-1 overflow-y-auto">
43
+ {!schemas || schemas.length === 0 ? (
44
+ <div className="p-4 text-center text-muted-foreground">
45
+ <Database className="mx-auto h-8 w-8 mb-2 opacity-50" />
46
+ <p className="text-xs">No schemas yet</p>
47
+ </div>
48
+ ) : (
49
+ <div className="py-1">
50
+ {schemas.map((schema) => {
51
+ const name = names(schema)
52
+ const isSelected = schemaName === name.key
53
+ return (
54
+ <button
55
+ key={schema}
56
+ type="button"
57
+ onClick={() => navigate(`/playground/${name.key}`)}
58
+ className={`w-full text-left px-3 py-2 text-sm transition-colors ${
59
+ isSelected
60
+ ? 'bg-accent text-accent-foreground'
61
+ : 'hover:bg-muted/50 text-muted-foreground hover:text-foreground'
62
+ }`}
63
+ >
64
+ <div className="font-medium truncate">{name.title}</div>
65
+ <div className="text-xs opacity-70 font-mono truncate">
66
+ {name.key}
67
+ </div>
68
+ </button>
69
+ )
70
+ })}
71
+ </div>
72
+ )}
73
+ </div>
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,109 @@
1
+ import { useDialog } from '@magnet-cms/admin'
2
+ import { Button, Switch } from '@magnet-cms/ui/components'
3
+ import { useEffect } from 'react'
4
+ import { useSchemaBuilder } from '../hooks/useSchemaBuilder'
5
+
6
+ /**
7
+ * Schema Options Content - rendered inside the dialog
8
+ */
9
+ function SchemaOptionsContent({ onClose }: { onClose: () => void }) {
10
+ const { state, updateSchema } = useSchemaBuilder()
11
+
12
+ return (
13
+ <div className="space-y-4">
14
+ <div className="flex items-center justify-between">
15
+ <div className="space-y-0.5">
16
+ <label
17
+ htmlFor="versioning"
18
+ className="text-sm font-medium cursor-pointer"
19
+ >
20
+ Enable Versioning
21
+ </label>
22
+ <p className="text-xs text-muted-foreground">
23
+ Track content changes with version history
24
+ </p>
25
+ </div>
26
+ <Switch
27
+ id="versioning"
28
+ checked={state.schema.versioning}
29
+ onCheckedChange={(checked) => updateSchema({ versioning: checked })}
30
+ />
31
+ </div>
32
+
33
+ <div className="flex items-center justify-between">
34
+ <div className="space-y-0.5">
35
+ <label htmlFor="i18n" className="text-sm font-medium cursor-pointer">
36
+ Enable i18n
37
+ </label>
38
+ <p className="text-xs text-muted-foreground">
39
+ Support multiple languages for content
40
+ </p>
41
+ </div>
42
+ <Switch
43
+ id="i18n"
44
+ checked={state.schema.i18n}
45
+ onCheckedChange={(checked) => updateSchema({ i18n: checked })}
46
+ />
47
+ </div>
48
+
49
+ <div className="flex justify-end pt-2">
50
+ <Button variant="outline" onClick={onClose}>
51
+ Close
52
+ </Button>
53
+ </div>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Hook to show schema options dialog
60
+ */
61
+ export function useSchemaOptionsDialog() {
62
+ const { showDialog, closeDialog } = useDialog()
63
+
64
+ return {
65
+ open: () => {
66
+ showDialog({
67
+ title: 'Schema Options',
68
+ description:
69
+ 'Configure schema-level settings for versioning and internationalization.',
70
+ size: 'md',
71
+ content: <SchemaOptionsContent onClose={closeDialog} />,
72
+ })
73
+ },
74
+ close: closeDialog,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Legacy component wrapper for backwards compatibility
80
+ */
81
+ interface SchemaOptionsDialogProps {
82
+ open: boolean
83
+ onOpenChange: (open: boolean) => void
84
+ }
85
+
86
+ export function SchemaOptionsDialog({
87
+ open,
88
+ onOpenChange,
89
+ }: SchemaOptionsDialogProps) {
90
+ const schemaOptions = useSchemaOptionsDialog()
91
+
92
+ useEffect(() => {
93
+ if (open) {
94
+ schemaOptions.open()
95
+ }
96
+ }, [open, schemaOptions])
97
+
98
+ // When dialog closes via the service, notify parent
99
+ useEffect(() => {
100
+ // This is a simplified approach - the dialog service handles closing
101
+ return () => {
102
+ if (open) {
103
+ onOpenChange(false)
104
+ }
105
+ }
106
+ }, [open, onOpenChange])
107
+
108
+ return null // Dialog is rendered by DialogProvider in admin
109
+ }