@opensaas/stack-ui 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.
Files changed (203) hide show
  1. package/.turbo/turbo-build.log +8 -0
  2. package/README.md +286 -0
  3. package/dist/components/AdminUI.d.ts +24 -0
  4. package/dist/components/AdminUI.d.ts.map +1 -0
  5. package/dist/components/AdminUI.js +48 -0
  6. package/dist/components/ConfirmDialog.d.ts +16 -0
  7. package/dist/components/ConfirmDialog.d.ts.map +1 -0
  8. package/dist/components/ConfirmDialog.js +11 -0
  9. package/dist/components/Dashboard.d.ts +12 -0
  10. package/dist/components/Dashboard.d.ts.map +1 -0
  11. package/dist/components/Dashboard.js +30 -0
  12. package/dist/components/ItemForm.d.ts +17 -0
  13. package/dist/components/ItemForm.d.ts.map +1 -0
  14. package/dist/components/ItemForm.js +97 -0
  15. package/dist/components/ItemFormClient.d.ts +22 -0
  16. package/dist/components/ItemFormClient.d.ts.map +1 -0
  17. package/dist/components/ItemFormClient.js +127 -0
  18. package/dist/components/ListView.d.ts +17 -0
  19. package/dist/components/ListView.d.ts.map +1 -0
  20. package/dist/components/ListView.js +76 -0
  21. package/dist/components/ListViewClient.d.ts +19 -0
  22. package/dist/components/ListViewClient.d.ts.map +1 -0
  23. package/dist/components/ListViewClient.js +108 -0
  24. package/dist/components/LoadingSpinner.d.ts +10 -0
  25. package/dist/components/LoadingSpinner.d.ts.map +1 -0
  26. package/dist/components/LoadingSpinner.js +14 -0
  27. package/dist/components/Navigation.d.ts +13 -0
  28. package/dist/components/Navigation.d.ts.map +1 -0
  29. package/dist/components/Navigation.js +20 -0
  30. package/dist/components/SkeletonLoader.d.ts +22 -0
  31. package/dist/components/SkeletonLoader.d.ts.map +1 -0
  32. package/dist/components/SkeletonLoader.js +25 -0
  33. package/dist/components/fields/CheckboxField.d.ts +11 -0
  34. package/dist/components/fields/CheckboxField.d.ts.map +1 -0
  35. package/dist/components/fields/CheckboxField.js +10 -0
  36. package/dist/components/fields/ComboboxField.d.ts +18 -0
  37. package/dist/components/fields/ComboboxField.d.ts.map +1 -0
  38. package/dist/components/fields/ComboboxField.js +32 -0
  39. package/dist/components/fields/FieldRenderer.d.ts +22 -0
  40. package/dist/components/fields/FieldRenderer.d.ts.map +1 -0
  41. package/dist/components/fields/FieldRenderer.js +81 -0
  42. package/dist/components/fields/IntegerField.d.ts +15 -0
  43. package/dist/components/fields/IntegerField.d.ts.map +1 -0
  44. package/dist/components/fields/IntegerField.js +14 -0
  45. package/dist/components/fields/PasswordField.d.ts +18 -0
  46. package/dist/components/fields/PasswordField.d.ts.map +1 -0
  47. package/dist/components/fields/PasswordField.js +42 -0
  48. package/dist/components/fields/RelationshipField.d.ts +20 -0
  49. package/dist/components/fields/RelationshipField.d.ts.map +1 -0
  50. package/dist/components/fields/RelationshipField.js +11 -0
  51. package/dist/components/fields/RelationshipManager.d.ts +19 -0
  52. package/dist/components/fields/RelationshipManager.d.ts.map +1 -0
  53. package/dist/components/fields/RelationshipManager.js +37 -0
  54. package/dist/components/fields/SelectField.d.ts +16 -0
  55. package/dist/components/fields/SelectField.d.ts.map +1 -0
  56. package/dist/components/fields/SelectField.js +11 -0
  57. package/dist/components/fields/TextField.d.ts +13 -0
  58. package/dist/components/fields/TextField.d.ts.map +1 -0
  59. package/dist/components/fields/TextField.js +11 -0
  60. package/dist/components/fields/TimestampField.d.ts +12 -0
  61. package/dist/components/fields/TimestampField.d.ts.map +1 -0
  62. package/dist/components/fields/TimestampField.js +12 -0
  63. package/dist/components/fields/index.d.ts +23 -0
  64. package/dist/components/fields/index.d.ts.map +1 -0
  65. package/dist/components/fields/index.js +13 -0
  66. package/dist/components/fields/registry.d.ts +43 -0
  67. package/dist/components/fields/registry.d.ts.map +1 -0
  68. package/dist/components/fields/registry.js +42 -0
  69. package/dist/components/standalone/DeleteButton.d.ts +35 -0
  70. package/dist/components/standalone/DeleteButton.d.ts.map +1 -0
  71. package/dist/components/standalone/DeleteButton.js +46 -0
  72. package/dist/components/standalone/ItemCreateForm.d.ts +34 -0
  73. package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -0
  74. package/dist/components/standalone/ItemCreateForm.js +91 -0
  75. package/dist/components/standalone/ItemEditForm.d.ts +37 -0
  76. package/dist/components/standalone/ItemEditForm.d.ts.map +1 -0
  77. package/dist/components/standalone/ItemEditForm.js +112 -0
  78. package/dist/components/standalone/ListTable.d.ts +33 -0
  79. package/dist/components/standalone/ListTable.d.ts.map +1 -0
  80. package/dist/components/standalone/ListTable.js +94 -0
  81. package/dist/components/standalone/SearchBar.d.ts +29 -0
  82. package/dist/components/standalone/SearchBar.d.ts.map +1 -0
  83. package/dist/components/standalone/SearchBar.js +43 -0
  84. package/dist/components/standalone/index.d.ts +11 -0
  85. package/dist/components/standalone/index.d.ts.map +1 -0
  86. package/dist/components/standalone/index.js +6 -0
  87. package/dist/index.d.ts +27 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +19 -0
  90. package/dist/lib/serializeFieldConfig.d.ts +43 -0
  91. package/dist/lib/serializeFieldConfig.d.ts.map +1 -0
  92. package/dist/lib/serializeFieldConfig.js +48 -0
  93. package/dist/lib/theme.d.ts +17 -0
  94. package/dist/lib/theme.d.ts.map +1 -0
  95. package/dist/lib/theme.js +192 -0
  96. package/dist/lib/utils.d.ts +18 -0
  97. package/dist/lib/utils.d.ts.map +1 -0
  98. package/dist/lib/utils.js +76 -0
  99. package/dist/primitives/button.d.ts +12 -0
  100. package/dist/primitives/button.d.ts.map +1 -0
  101. package/dist/primitives/button.js +33 -0
  102. package/dist/primitives/calendar.d.ts +9 -0
  103. package/dist/primitives/calendar.d.ts.map +1 -0
  104. package/dist/primitives/calendar.js +48 -0
  105. package/dist/primitives/card.d.ts +9 -0
  106. package/dist/primitives/card.d.ts.map +1 -0
  107. package/dist/primitives/card.js +16 -0
  108. package/dist/primitives/checkbox.d.ts +5 -0
  109. package/dist/primitives/checkbox.d.ts.map +1 -0
  110. package/dist/primitives/checkbox.js +7 -0
  111. package/dist/primitives/combobox.d.ts +14 -0
  112. package/dist/primitives/combobox.d.ts.map +1 -0
  113. package/dist/primitives/combobox.js +20 -0
  114. package/dist/primitives/datetime-picker.d.ts +9 -0
  115. package/dist/primitives/datetime-picker.d.ts.map +1 -0
  116. package/dist/primitives/datetime-picker.js +42 -0
  117. package/dist/primitives/dialog.d.ts +20 -0
  118. package/dist/primitives/dialog.d.ts.map +1 -0
  119. package/dist/primitives/dialog.js +21 -0
  120. package/dist/primitives/index.d.ts +14 -0
  121. package/dist/primitives/index.d.ts.map +1 -0
  122. package/dist/primitives/index.js +14 -0
  123. package/dist/primitives/input.d.ts +5 -0
  124. package/dist/primitives/input.d.ts.map +1 -0
  125. package/dist/primitives/input.js +8 -0
  126. package/dist/primitives/label.d.ts +6 -0
  127. package/dist/primitives/label.d.ts.map +1 -0
  128. package/dist/primitives/label.js +9 -0
  129. package/dist/primitives/popover.d.ts +7 -0
  130. package/dist/primitives/popover.d.ts.map +1 -0
  131. package/dist/primitives/popover.js +10 -0
  132. package/dist/primitives/select.d.ts +14 -0
  133. package/dist/primitives/select.d.ts.map +1 -0
  134. package/dist/primitives/select.js +24 -0
  135. package/dist/primitives/table.d.ts +11 -0
  136. package/dist/primitives/table.d.ts.map +1 -0
  137. package/dist/primitives/table.js +20 -0
  138. package/dist/primitives/time-picker.d.ts +8 -0
  139. package/dist/primitives/time-picker.d.ts.map +1 -0
  140. package/dist/primitives/time-picker.js +27 -0
  141. package/dist/server/index.d.ts +2 -0
  142. package/dist/server/index.d.ts.map +1 -0
  143. package/dist/server/index.js +2 -0
  144. package/dist/server/types.d.ts +15 -0
  145. package/dist/server/types.d.ts.map +1 -0
  146. package/dist/server/types.js +1 -0
  147. package/dist/styles/globals.css +1896 -0
  148. package/package.json +91 -0
  149. package/postcss.config.cjs +5 -0
  150. package/src/components/AdminUI.tsx +112 -0
  151. package/src/components/ConfirmDialog.tsx +56 -0
  152. package/src/components/Dashboard.tsx +134 -0
  153. package/src/components/ItemForm.tsx +195 -0
  154. package/src/components/ItemFormClient.tsx +237 -0
  155. package/src/components/ListView.tsx +153 -0
  156. package/src/components/ListViewClient.tsx +282 -0
  157. package/src/components/LoadingSpinner.tsx +32 -0
  158. package/src/components/Navigation.tsx +117 -0
  159. package/src/components/SkeletonLoader.tsx +82 -0
  160. package/src/components/fields/CheckboxField.tsx +54 -0
  161. package/src/components/fields/ComboboxField.tsx +127 -0
  162. package/src/components/fields/FieldRenderer.tsx +132 -0
  163. package/src/components/fields/IntegerField.tsx +68 -0
  164. package/src/components/fields/PasswordField.tsx +159 -0
  165. package/src/components/fields/RelationshipField.tsx +71 -0
  166. package/src/components/fields/RelationshipManager.tsx +189 -0
  167. package/src/components/fields/SelectField.tsx +71 -0
  168. package/src/components/fields/TextField.tsx +59 -0
  169. package/src/components/fields/TimestampField.tsx +49 -0
  170. package/src/components/fields/index.ts +27 -0
  171. package/src/components/fields/registry.ts +72 -0
  172. package/src/components/standalone/DeleteButton.tsx +114 -0
  173. package/src/components/standalone/ItemCreateForm.tsx +161 -0
  174. package/src/components/standalone/ItemEditForm.tsx +193 -0
  175. package/src/components/standalone/ListTable.tsx +211 -0
  176. package/src/components/standalone/SearchBar.tsx +86 -0
  177. package/src/components/standalone/index.ts +13 -0
  178. package/src/index.ts +74 -0
  179. package/src/lib/serializeFieldConfig.ts +88 -0
  180. package/src/lib/theme.ts +202 -0
  181. package/src/lib/utils.ts +81 -0
  182. package/src/primitives/button.tsx +49 -0
  183. package/src/primitives/calendar.tsx +160 -0
  184. package/src/primitives/card.tsx +58 -0
  185. package/src/primitives/checkbox.tsx +27 -0
  186. package/src/primitives/combobox.tsx +159 -0
  187. package/src/primitives/datetime-picker.tsx +130 -0
  188. package/src/primitives/dialog.tsx +108 -0
  189. package/src/primitives/index.ts +54 -0
  190. package/src/primitives/input.tsx +24 -0
  191. package/src/primitives/label.tsx +19 -0
  192. package/src/primitives/popover.tsx +31 -0
  193. package/src/primitives/select.tsx +158 -0
  194. package/src/primitives/table.tsx +91 -0
  195. package/src/primitives/time-picker.tsx +65 -0
  196. package/src/server/index.ts +3 -0
  197. package/src/server/types.ts +15 -0
  198. package/src/styles/globals.css +123 -0
  199. package/tailwind.config.ts +3 -0
  200. package/tests/components/TextField.test.tsx +94 -0
  201. package/tests/setup.ts +11 -0
  202. package/tsconfig.json +26 -0
  203. package/vitest.config.ts +22 -0
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@opensaas/stack-ui",
3
+ "version": "0.1.0",
4
+ "description": "Composable React UI components for OpenSaas Stack",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./primitives": {
14
+ "types": "./dist/primitives/index.d.ts",
15
+ "default": "./dist/primitives/index.js"
16
+ },
17
+ "./fields": {
18
+ "types": "./dist/components/fields/index.d.ts",
19
+ "default": "./dist/components/fields/index.js"
20
+ },
21
+ "./standalone": {
22
+ "types": "./dist/components/standalone/index.d.ts",
23
+ "default": "./dist/components/standalone/index.js"
24
+ },
25
+ "./server": {
26
+ "types": "./dist/server/index.d.ts",
27
+ "default": "./dist/server/index.js"
28
+ },
29
+ "./lib/utils": {
30
+ "types": "./dist/lib/utils.d.ts",
31
+ "default": "./dist/lib/utils.js"
32
+ },
33
+ "./styles": "./dist/styles/globals.css"
34
+ },
35
+ "keywords": [
36
+ "opensaas",
37
+ "admin",
38
+ "ui",
39
+ "nextjs",
40
+ "react"
41
+ ],
42
+ "author": "",
43
+ "license": "MIT",
44
+ "peerDependencies": {
45
+ "next": "^15.0.0 || ^16.0.0",
46
+ "react": "^19.0.0",
47
+ "react-dom": "^19.0.0",
48
+ "@opensaas/stack-core": "0.1.0"
49
+ },
50
+ "dependencies": {
51
+ "@radix-ui/react-checkbox": "^1.3.3",
52
+ "@radix-ui/react-dialog": "^1.1.15",
53
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
54
+ "@radix-ui/react-label": "^2.1.7",
55
+ "@radix-ui/react-popover": "^1.1.15",
56
+ "@radix-ui/react-select": "^2.2.6",
57
+ "@radix-ui/react-separator": "^1.1.7",
58
+ "@radix-ui/react-slot": "^1.2.3",
59
+ "class-variance-authority": "^0.7.1",
60
+ "clsx": "^2.1.1",
61
+ "date-fns": "^4.1.0",
62
+ "react-hook-form": "^7.54.2",
63
+ "tailwind-merge": "^3.3.1"
64
+ },
65
+ "devDependencies": {
66
+ "@tailwindcss/postcss": "^4.0.0",
67
+ "@testing-library/jest-dom": "^6.9.1",
68
+ "@testing-library/react": "^16.1.0",
69
+ "@testing-library/user-event": "^14.5.2",
70
+ "@types/node": "^24.7.2",
71
+ "@types/react": "^19.2.2",
72
+ "@types/react-dom": "^19.2.2",
73
+ "@vitejs/plugin-react": "^5.0.0",
74
+ "happy-dom": "^20.0.0",
75
+ "postcss": "^8.4.49",
76
+ "postcss-cli": "^11.0.0",
77
+ "tailwindcss": "^4.0.0",
78
+ "typescript": "^5.9.3",
79
+ "vitest": "^4.0.0",
80
+ "@opensaas/stack-core": "0.1.0"
81
+ },
82
+ "scripts": {
83
+ "build": "tsc && npm run build:css",
84
+ "build:css": "mkdir -p dist/styles && postcss ./src/styles/globals.css -o ./dist/styles/globals.css",
85
+ "dev": "tsc --watch",
86
+ "test": "vitest",
87
+ "test:ui": "vitest --ui",
88
+ "test:coverage": "vitest --coverage",
89
+ "clean": "rm -rf .turbo dist tsconfig.tsbuildinfo"
90
+ }
91
+ }
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
@@ -0,0 +1,112 @@
1
+ import * as React from 'react'
2
+ import { Navigation } from './Navigation.js'
3
+ import { Dashboard } from './Dashboard.js'
4
+ import { ListView } from './ListView.js'
5
+ import { ItemForm } from './ItemForm.js'
6
+ import type { ServerActionInput } from '../server/types.js'
7
+ import { AccessContext, getListKeyFromUrl, OpenSaasConfig } from '@opensaas/stack-core'
8
+ import { generateThemeCSS } from '../lib/theme.js'
9
+
10
+ export interface AdminUIProps<TPrisma> {
11
+ context: AccessContext<TPrisma>
12
+ config: OpenSaasConfig
13
+ params?: string[]
14
+ searchParams?: { [key: string]: string | string[] | undefined }
15
+ basePath?: string
16
+ // Server action can return any shape depending on the list item type
17
+ serverAction: (input: ServerActionInput) => Promise<unknown>
18
+ }
19
+
20
+ /**
21
+ * Main AdminUI component - complete admin interface with routing
22
+ * Server Component
23
+ *
24
+ * Handles routing based on params array:
25
+ * - [] → Dashboard
26
+ * - [list] → ListView
27
+ * - [list, 'create'] → ItemForm (create)
28
+ * - [list, id] → ItemForm (edit)
29
+ */
30
+ export function AdminUI<TPrisma>({
31
+ context,
32
+ config,
33
+ params = [],
34
+ searchParams = {},
35
+ basePath = '/admin',
36
+ serverAction,
37
+ }: AdminUIProps<TPrisma>) {
38
+ // Parse route from params
39
+ const [urlSegment, action] = params
40
+
41
+ // Convert URL segment (kebab-case) to PascalCase listKey
42
+ const listKey = urlSegment ? getListKeyFromUrl(urlSegment) : undefined
43
+
44
+ // Determine current path for navigation highlighting
45
+ const currentPath = params.length > 0 ? `/${params.join('/')}` : ''
46
+
47
+ // Route to appropriate component
48
+ let content: React.ReactNode
49
+
50
+ if (!listKey) {
51
+ // Dashboard
52
+ content = <Dashboard context={context} config={config} basePath={basePath} />
53
+ } else if (action === 'create') {
54
+ // Create form
55
+ content = (
56
+ <ItemForm
57
+ context={context}
58
+ config={config}
59
+ listKey={listKey}
60
+ mode="create"
61
+ basePath={basePath}
62
+ serverAction={serverAction}
63
+ />
64
+ )
65
+ } else if (action && action !== 'create') {
66
+ // Edit form (action is the item ID)
67
+ content = (
68
+ <ItemForm
69
+ context={context}
70
+ config={config}
71
+ listKey={listKey}
72
+ mode="edit"
73
+ itemId={action}
74
+ basePath={basePath}
75
+ serverAction={serverAction}
76
+ />
77
+ )
78
+ } else {
79
+ // List view
80
+ const search = typeof searchParams.search === 'string' ? searchParams.search : undefined
81
+ const page = typeof searchParams.page === 'string' ? parseInt(searchParams.page, 10) : 1
82
+
83
+ content = (
84
+ <ListView
85
+ context={context}
86
+ config={config}
87
+ listKey={listKey}
88
+ basePath={basePath}
89
+ search={search}
90
+ page={page}
91
+ />
92
+ )
93
+ }
94
+
95
+ // Generate theme styles if custom theme is configured
96
+ const themeStyles = config.ui?.theme ? generateThemeCSS(config.ui.theme) : null
97
+
98
+ return (
99
+ <>
100
+ {themeStyles && <style dangerouslySetInnerHTML={{ __html: themeStyles }} />}
101
+ <div className="flex min-h-screen bg-background">
102
+ <Navigation
103
+ context={context}
104
+ config={config}
105
+ basePath={basePath}
106
+ currentPath={currentPath}
107
+ />
108
+ <main className="flex-1 overflow-y-auto">{content}</main>
109
+ </div>
110
+ </>
111
+ )
112
+ }
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from '../primitives/dialog.js'
11
+ import { Button } from '../primitives/button.js'
12
+
13
+ export interface ConfirmDialogProps {
14
+ isOpen: boolean
15
+ title: string
16
+ message: string
17
+ confirmLabel?: string
18
+ cancelLabel?: string
19
+ onConfirm: () => void
20
+ onCancel: () => void
21
+ variant?: 'danger' | 'warning'
22
+ }
23
+
24
+ /**
25
+ * Reusable confirmation dialog component
26
+ * Used for destructive actions like delete
27
+ */
28
+ export function ConfirmDialog({
29
+ isOpen,
30
+ title,
31
+ message,
32
+ confirmLabel = 'Confirm',
33
+ cancelLabel = 'Cancel',
34
+ onConfirm,
35
+ onCancel,
36
+ variant = 'danger',
37
+ }: ConfirmDialogProps) {
38
+ return (
39
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
40
+ <DialogContent>
41
+ <DialogHeader>
42
+ <DialogTitle>{title}</DialogTitle>
43
+ <DialogDescription>{message}</DialogDescription>
44
+ </DialogHeader>
45
+ <DialogFooter>
46
+ <Button variant="outline" onClick={onCancel}>
47
+ {cancelLabel}
48
+ </Button>
49
+ <Button variant={variant === 'danger' ? 'destructive' : 'default'} onClick={onConfirm}>
50
+ {confirmLabel}
51
+ </Button>
52
+ </DialogFooter>
53
+ </DialogContent>
54
+ </Dialog>
55
+ )
56
+ }
@@ -0,0 +1,134 @@
1
+ import Link from 'next/link'
2
+ import { formatListName } from '../lib/utils.js'
3
+ import { AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '../primitives/card.js'
5
+
6
+ export interface DashboardProps<TPrisma> {
7
+ context: AccessContext<TPrisma>
8
+ config: OpenSaasConfig
9
+ basePath?: string
10
+ }
11
+
12
+ /**
13
+ * Dashboard landing page showing all available lists
14
+ * Server Component
15
+ */
16
+ export async function Dashboard<TPrisma>({
17
+ context,
18
+ config,
19
+ basePath = '/admin',
20
+ }: DashboardProps<TPrisma>) {
21
+ const lists = Object.keys(config.lists || {})
22
+
23
+ // Get counts for each list
24
+ const listCounts = await Promise.all(
25
+ lists.map(async (listKey) => {
26
+ try {
27
+ const count = await context.db[getDbKey(listKey)]?.count()
28
+ return { listKey, count: count || 0 }
29
+ } catch (error) {
30
+ console.error(`Failed to get count for ${listKey}:`, error)
31
+ return { listKey, count: 0 }
32
+ }
33
+ }),
34
+ )
35
+
36
+ return (
37
+ <div className="p-8">
38
+ {/* Header with gradient */}
39
+ <div className="mb-8 relative">
40
+ <div className="absolute inset-0 bg-gradient-to-r from-primary/5 to-accent/5 opacity-100 rounded-2xl" />
41
+ <div className="relative p-6">
42
+ <h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
43
+ Dashboard
44
+ </h1>
45
+ <p className="text-muted-foreground">Manage your application data</p>
46
+ </div>
47
+ </div>
48
+
49
+ {lists.length === 0 ? (
50
+ <Card className="p-12 text-center border-2 border-dashed">
51
+ <div className="mb-4 text-4xl">📦</div>
52
+ <p className="text-muted-foreground mb-2 font-medium">No lists configured</p>
53
+ <p className="text-sm text-muted-foreground">
54
+ Add lists to your opensaas.config.ts to get started.
55
+ </p>
56
+ </Card>
57
+ ) : (
58
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
59
+ {listCounts.map(({ listKey, count }) => {
60
+ const urlKey = getUrlKey(listKey)
61
+ return (
62
+ <Link key={listKey} href={`${basePath}/${urlKey}`}>
63
+ <Card className="group hover:border-primary hover:shadow-lg hover:shadow-primary/20 transition-all duration-200 cursor-pointer h-full relative overflow-hidden">
64
+ <div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
65
+ <CardHeader className="relative">
66
+ <div className="flex items-start justify-between">
67
+ <div>
68
+ <CardTitle className="text-xl group-hover:text-primary transition-colors">
69
+ {formatListName(listKey)}
70
+ </CardTitle>
71
+ <p className="text-sm text-muted-foreground mt-1 font-medium">
72
+ {count} {count === 1 ? 'item' : 'items'}
73
+ </p>
74
+ </div>
75
+ <div className="text-3xl opacity-60 group-hover:opacity-100 transition-opacity">
76
+ 📋
77
+ </div>
78
+ </div>
79
+ </CardHeader>
80
+ <CardContent className="relative">
81
+ <div className="flex items-center text-sm font-medium text-primary">
82
+ <span>View all</span>
83
+ <svg
84
+ className="ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ viewBox="0 0 24 24"
88
+ >
89
+ <path
90
+ strokeLinecap="round"
91
+ strokeLinejoin="round"
92
+ strokeWidth={2}
93
+ d="M9 5l7 7-7 7"
94
+ />
95
+ </svg>
96
+ </div>
97
+ </CardContent>
98
+ </Card>
99
+ </Link>
100
+ )
101
+ })}
102
+ </div>
103
+ )}
104
+
105
+ {lists.length > 0 && (
106
+ <Card className="mt-12 bg-gradient-to-br from-accent/10 to-primary/10 border-accent/20">
107
+ <CardHeader>
108
+ <CardTitle className="text-lg flex items-center gap-2">
109
+ <span className="text-xl">⚡</span>
110
+ Quick Actions
111
+ </CardTitle>
112
+ </CardHeader>
113
+ <CardContent>
114
+ <div className="flex flex-wrap gap-3">
115
+ {lists.map((listKey) => {
116
+ const urlKey = getUrlKey(listKey)
117
+ return (
118
+ <Link
119
+ key={listKey}
120
+ href={`${basePath}/${urlKey}/create`}
121
+ className="inline-flex items-center gap-1 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground font-medium text-sm transition-colors border border-primary/20"
122
+ >
123
+ <span className="text-lg">+</span>
124
+ Create {formatListName(listKey)}
125
+ </Link>
126
+ )
127
+ })}
128
+ </div>
129
+ </CardContent>
130
+ </Card>
131
+ )}
132
+ </div>
133
+ )
134
+ }
@@ -0,0 +1,195 @@
1
+ import * as React from 'react'
2
+ import Link from 'next/link'
3
+ import { ItemFormClient } from './ItemFormClient.js'
4
+ import { formatListName } from '../lib/utils.js'
5
+ import type { ServerActionInput } from '../server/types.js'
6
+ import { AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
7
+ import { serializeFieldConfigs } from '../lib/serializeFieldConfig.js'
8
+
9
+ export interface ItemFormProps<TPrisma> {
10
+ context: AccessContext<TPrisma>
11
+ config: OpenSaasConfig
12
+ listKey: string
13
+ mode: 'create' | 'edit'
14
+ itemId?: string
15
+ basePath?: string
16
+ // Server action can return any shape depending on the list item type
17
+ serverAction: (input: ServerActionInput) => Promise<unknown>
18
+ }
19
+
20
+ /**
21
+ * Item form component - create or edit an item
22
+ * Server Component that fetches data and sets up actions
23
+ */
24
+ export async function ItemForm<TPrisma>({
25
+ context,
26
+ config,
27
+ listKey,
28
+ mode,
29
+ itemId,
30
+ basePath = '/admin',
31
+ serverAction,
32
+ }: ItemFormProps<TPrisma>) {
33
+ const listConfig = config.lists[listKey]
34
+ const urlKey = getUrlKey(listKey)
35
+
36
+ if (!listConfig) {
37
+ return (
38
+ <div className="p-8">
39
+ <div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-6">
40
+ <h2 className="text-lg font-semibold mb-2">List not found</h2>
41
+ <p>The list &quot;{listKey}&quot; does not exist in your configuration.</p>
42
+ </div>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ // Fetch item data if in edit mode
48
+ let itemData: Record<string, unknown> = {}
49
+ if (mode === 'edit' && itemId) {
50
+ try {
51
+ // Build include object for relationships
52
+ const includeRelationships: Record<string, boolean> = {}
53
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
54
+ const fieldConfigAny = fieldConfig as { type: string }
55
+ if (fieldConfigAny.type === 'relationship') {
56
+ includeRelationships[fieldName] = true
57
+ }
58
+ }
59
+
60
+ // Fetch item with relationships included
61
+ itemData = await context.db[getDbKey(listKey)].findUnique({
62
+ where: { id: itemId },
63
+ ...(Object.keys(includeRelationships).length > 0 && { include: includeRelationships }),
64
+ })
65
+ } catch (error) {
66
+ console.error(`Failed to fetch item ${itemId}:`, error)
67
+ }
68
+
69
+ if (!itemData) {
70
+ return (
71
+ <div className="p-8">
72
+ <div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-6">
73
+ <h2 className="text-lg font-semibold mb-2">Item not found</h2>
74
+ <p>
75
+ The item you&apos;re trying to edit doesn&apos;t exist or you don&apos;t have access
76
+ to it.
77
+ </p>
78
+ <Link
79
+ href={`${basePath}/${urlKey}`}
80
+ className="inline-block mt-4 text-primary hover:underline"
81
+ >
82
+ ← Back to {formatListName(listKey)}
83
+ </Link>
84
+ </div>
85
+ </div>
86
+ )
87
+ }
88
+ }
89
+
90
+ // Fetch relationship options for all relationship fields
91
+ const relationshipData: Record<string, Array<{ id: string; label: string }>> = {}
92
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
93
+ // Check if field is a relationship type by checking the discriminated union
94
+ const fieldConfigAny = fieldConfig as { type: string; ref?: string }
95
+ if (fieldConfigAny.type === 'relationship') {
96
+ const ref = fieldConfigAny.ref
97
+ if (ref) {
98
+ // Parse ref format: "ListName.fieldName"
99
+ const relatedListName = ref.split('.')[0]
100
+ const relatedListConfig = config.lists[relatedListName]
101
+
102
+ if (relatedListConfig) {
103
+ try {
104
+ const dbContext = context.db
105
+ const relatedItems = await dbContext[getDbKey(relatedListName)].findMany({})
106
+
107
+ // Use 'name' field as label if it exists, otherwise use 'id'
108
+ relationshipData[fieldName] = relatedItems.map((item: Record<string, unknown>) => ({
109
+ id: item.id as string,
110
+ label: ((item.name || item.title || item.id) as string) || '',
111
+ }))
112
+ } catch (error) {
113
+ console.error(`Failed to fetch relationship items for ${fieldName}:`, error)
114
+ relationshipData[fieldName] = []
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Serialize field configs to remove non-serializable properties
122
+ const serializableFields = serializeFieldConfigs(listConfig.fields)
123
+
124
+ // Transform relationship data in itemData from objects to IDs for form
125
+ // Also apply valueForClientSerialization transformation
126
+ const formData = { ...itemData }
127
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
128
+ const fieldConfigAny = fieldConfig as {
129
+ type: string
130
+ many?: boolean
131
+ ui?: Record<string, unknown>
132
+ }
133
+ if (fieldConfigAny.type === 'relationship' && formData[fieldName]) {
134
+ const value = formData[fieldName]
135
+ if (fieldConfigAny.many && Array.isArray(value)) {
136
+ // Many relationship: extract IDs from array of objects
137
+ formData[fieldName] = value.map((item: Record<string, unknown>) => item.id as string)
138
+ } else if (value && typeof value === 'object' && 'id' in value) {
139
+ // Single relationship: extract ID from object
140
+ formData[fieldName] = (value as Record<string, unknown>).id as string
141
+ }
142
+ }
143
+
144
+ // Apply valueForClientSerialization if defined
145
+ if (
146
+ fieldConfigAny.ui?.valueForClientSerialization &&
147
+ typeof fieldConfigAny.ui.valueForClientSerialization === 'function'
148
+ ) {
149
+ const transformer = fieldConfigAny.ui.valueForClientSerialization as (args: {
150
+ value: unknown
151
+ }) => unknown
152
+ formData[fieldName] = transformer({ value: formData[fieldName] })
153
+ }
154
+ }
155
+
156
+ return (
157
+ <div className="p-8 max-w-4xl">
158
+ {/* Header */}
159
+ <div className="mb-8">
160
+ <Link
161
+ href={`${basePath}/${urlKey}`}
162
+ className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
163
+ >
164
+ <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
165
+ <path
166
+ strokeLinecap="round"
167
+ strokeLinejoin="round"
168
+ strokeWidth={2}
169
+ d="M15 19l-7-7 7-7"
170
+ />
171
+ </svg>
172
+ Back to {formatListName(listKey)}
173
+ </Link>
174
+ <h1 className="text-3xl font-bold">
175
+ {mode === 'create' ? 'Create' : 'Edit'} {formatListName(listKey)}
176
+ </h1>
177
+ </div>
178
+
179
+ {/* Form */}
180
+ <div className="bg-card border border-border rounded-lg p-6">
181
+ <ItemFormClient
182
+ listKey={listKey}
183
+ urlKey={urlKey}
184
+ mode={mode}
185
+ fields={serializableFields}
186
+ initialData={JSON.parse(JSON.stringify(formData))}
187
+ itemId={itemId}
188
+ basePath={basePath}
189
+ serverAction={serverAction}
190
+ relationshipData={relationshipData}
191
+ />
192
+ </div>
193
+ </div>
194
+ )
195
+ }