@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.
- package/.turbo/turbo-build.log +8 -0
- package/README.md +286 -0
- package/dist/components/AdminUI.d.ts +24 -0
- package/dist/components/AdminUI.d.ts.map +1 -0
- package/dist/components/AdminUI.js +48 -0
- package/dist/components/ConfirmDialog.d.ts +16 -0
- package/dist/components/ConfirmDialog.d.ts.map +1 -0
- package/dist/components/ConfirmDialog.js +11 -0
- package/dist/components/Dashboard.d.ts +12 -0
- package/dist/components/Dashboard.d.ts.map +1 -0
- package/dist/components/Dashboard.js +30 -0
- package/dist/components/ItemForm.d.ts +17 -0
- package/dist/components/ItemForm.d.ts.map +1 -0
- package/dist/components/ItemForm.js +97 -0
- package/dist/components/ItemFormClient.d.ts +22 -0
- package/dist/components/ItemFormClient.d.ts.map +1 -0
- package/dist/components/ItemFormClient.js +127 -0
- package/dist/components/ListView.d.ts +17 -0
- package/dist/components/ListView.d.ts.map +1 -0
- package/dist/components/ListView.js +76 -0
- package/dist/components/ListViewClient.d.ts +19 -0
- package/dist/components/ListViewClient.d.ts.map +1 -0
- package/dist/components/ListViewClient.js +108 -0
- package/dist/components/LoadingSpinner.d.ts +10 -0
- package/dist/components/LoadingSpinner.d.ts.map +1 -0
- package/dist/components/LoadingSpinner.js +14 -0
- package/dist/components/Navigation.d.ts +13 -0
- package/dist/components/Navigation.d.ts.map +1 -0
- package/dist/components/Navigation.js +20 -0
- package/dist/components/SkeletonLoader.d.ts +22 -0
- package/dist/components/SkeletonLoader.d.ts.map +1 -0
- package/dist/components/SkeletonLoader.js +25 -0
- package/dist/components/fields/CheckboxField.d.ts +11 -0
- package/dist/components/fields/CheckboxField.d.ts.map +1 -0
- package/dist/components/fields/CheckboxField.js +10 -0
- package/dist/components/fields/ComboboxField.d.ts +18 -0
- package/dist/components/fields/ComboboxField.d.ts.map +1 -0
- package/dist/components/fields/ComboboxField.js +32 -0
- package/dist/components/fields/FieldRenderer.d.ts +22 -0
- package/dist/components/fields/FieldRenderer.d.ts.map +1 -0
- package/dist/components/fields/FieldRenderer.js +81 -0
- package/dist/components/fields/IntegerField.d.ts +15 -0
- package/dist/components/fields/IntegerField.d.ts.map +1 -0
- package/dist/components/fields/IntegerField.js +14 -0
- package/dist/components/fields/PasswordField.d.ts +18 -0
- package/dist/components/fields/PasswordField.d.ts.map +1 -0
- package/dist/components/fields/PasswordField.js +42 -0
- package/dist/components/fields/RelationshipField.d.ts +20 -0
- package/dist/components/fields/RelationshipField.d.ts.map +1 -0
- package/dist/components/fields/RelationshipField.js +11 -0
- package/dist/components/fields/RelationshipManager.d.ts +19 -0
- package/dist/components/fields/RelationshipManager.d.ts.map +1 -0
- package/dist/components/fields/RelationshipManager.js +37 -0
- package/dist/components/fields/SelectField.d.ts +16 -0
- package/dist/components/fields/SelectField.d.ts.map +1 -0
- package/dist/components/fields/SelectField.js +11 -0
- package/dist/components/fields/TextField.d.ts +13 -0
- package/dist/components/fields/TextField.d.ts.map +1 -0
- package/dist/components/fields/TextField.js +11 -0
- package/dist/components/fields/TimestampField.d.ts +12 -0
- package/dist/components/fields/TimestampField.d.ts.map +1 -0
- package/dist/components/fields/TimestampField.js +12 -0
- package/dist/components/fields/index.d.ts +23 -0
- package/dist/components/fields/index.d.ts.map +1 -0
- package/dist/components/fields/index.js +13 -0
- package/dist/components/fields/registry.d.ts +43 -0
- package/dist/components/fields/registry.d.ts.map +1 -0
- package/dist/components/fields/registry.js +42 -0
- package/dist/components/standalone/DeleteButton.d.ts +35 -0
- package/dist/components/standalone/DeleteButton.d.ts.map +1 -0
- package/dist/components/standalone/DeleteButton.js +46 -0
- package/dist/components/standalone/ItemCreateForm.d.ts +34 -0
- package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -0
- package/dist/components/standalone/ItemCreateForm.js +91 -0
- package/dist/components/standalone/ItemEditForm.d.ts +37 -0
- package/dist/components/standalone/ItemEditForm.d.ts.map +1 -0
- package/dist/components/standalone/ItemEditForm.js +112 -0
- package/dist/components/standalone/ListTable.d.ts +33 -0
- package/dist/components/standalone/ListTable.d.ts.map +1 -0
- package/dist/components/standalone/ListTable.js +94 -0
- package/dist/components/standalone/SearchBar.d.ts +29 -0
- package/dist/components/standalone/SearchBar.d.ts.map +1 -0
- package/dist/components/standalone/SearchBar.js +43 -0
- package/dist/components/standalone/index.d.ts +11 -0
- package/dist/components/standalone/index.d.ts.map +1 -0
- package/dist/components/standalone/index.js +6 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/lib/serializeFieldConfig.d.ts +43 -0
- package/dist/lib/serializeFieldConfig.d.ts.map +1 -0
- package/dist/lib/serializeFieldConfig.js +48 -0
- package/dist/lib/theme.d.ts +17 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +192 -0
- package/dist/lib/utils.d.ts +18 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +76 -0
- package/dist/primitives/button.d.ts +12 -0
- package/dist/primitives/button.d.ts.map +1 -0
- package/dist/primitives/button.js +33 -0
- package/dist/primitives/calendar.d.ts +9 -0
- package/dist/primitives/calendar.d.ts.map +1 -0
- package/dist/primitives/calendar.js +48 -0
- package/dist/primitives/card.d.ts +9 -0
- package/dist/primitives/card.d.ts.map +1 -0
- package/dist/primitives/card.js +16 -0
- package/dist/primitives/checkbox.d.ts +5 -0
- package/dist/primitives/checkbox.d.ts.map +1 -0
- package/dist/primitives/checkbox.js +7 -0
- package/dist/primitives/combobox.d.ts +14 -0
- package/dist/primitives/combobox.d.ts.map +1 -0
- package/dist/primitives/combobox.js +20 -0
- package/dist/primitives/datetime-picker.d.ts +9 -0
- package/dist/primitives/datetime-picker.d.ts.map +1 -0
- package/dist/primitives/datetime-picker.js +42 -0
- package/dist/primitives/dialog.d.ts +20 -0
- package/dist/primitives/dialog.d.ts.map +1 -0
- package/dist/primitives/dialog.js +21 -0
- package/dist/primitives/index.d.ts +14 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +14 -0
- package/dist/primitives/input.d.ts +5 -0
- package/dist/primitives/input.d.ts.map +1 -0
- package/dist/primitives/input.js +8 -0
- package/dist/primitives/label.d.ts +6 -0
- package/dist/primitives/label.d.ts.map +1 -0
- package/dist/primitives/label.js +9 -0
- package/dist/primitives/popover.d.ts +7 -0
- package/dist/primitives/popover.d.ts.map +1 -0
- package/dist/primitives/popover.js +10 -0
- package/dist/primitives/select.d.ts +14 -0
- package/dist/primitives/select.d.ts.map +1 -0
- package/dist/primitives/select.js +24 -0
- package/dist/primitives/table.d.ts +11 -0
- package/dist/primitives/table.d.ts.map +1 -0
- package/dist/primitives/table.js +20 -0
- package/dist/primitives/time-picker.d.ts +8 -0
- package/dist/primitives/time-picker.d.ts.map +1 -0
- package/dist/primitives/time-picker.js +27 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/types.d.ts +15 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +1 -0
- package/dist/styles/globals.css +1896 -0
- package/package.json +91 -0
- package/postcss.config.cjs +5 -0
- package/src/components/AdminUI.tsx +112 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +134 -0
- package/src/components/ItemForm.tsx +195 -0
- package/src/components/ItemFormClient.tsx +237 -0
- package/src/components/ListView.tsx +153 -0
- package/src/components/ListViewClient.tsx +282 -0
- package/src/components/LoadingSpinner.tsx +32 -0
- package/src/components/Navigation.tsx +117 -0
- package/src/components/SkeletonLoader.tsx +82 -0
- package/src/components/fields/CheckboxField.tsx +54 -0
- package/src/components/fields/ComboboxField.tsx +127 -0
- package/src/components/fields/FieldRenderer.tsx +132 -0
- package/src/components/fields/IntegerField.tsx +68 -0
- package/src/components/fields/PasswordField.tsx +159 -0
- package/src/components/fields/RelationshipField.tsx +71 -0
- package/src/components/fields/RelationshipManager.tsx +189 -0
- package/src/components/fields/SelectField.tsx +71 -0
- package/src/components/fields/TextField.tsx +59 -0
- package/src/components/fields/TimestampField.tsx +49 -0
- package/src/components/fields/index.ts +27 -0
- package/src/components/fields/registry.ts +72 -0
- package/src/components/standalone/DeleteButton.tsx +114 -0
- package/src/components/standalone/ItemCreateForm.tsx +161 -0
- package/src/components/standalone/ItemEditForm.tsx +193 -0
- package/src/components/standalone/ListTable.tsx +211 -0
- package/src/components/standalone/SearchBar.tsx +86 -0
- package/src/components/standalone/index.ts +13 -0
- package/src/index.ts +74 -0
- package/src/lib/serializeFieldConfig.ts +88 -0
- package/src/lib/theme.ts +202 -0
- package/src/lib/utils.ts +81 -0
- package/src/primitives/button.tsx +49 -0
- package/src/primitives/calendar.tsx +160 -0
- package/src/primitives/card.tsx +58 -0
- package/src/primitives/checkbox.tsx +27 -0
- package/src/primitives/combobox.tsx +159 -0
- package/src/primitives/datetime-picker.tsx +130 -0
- package/src/primitives/dialog.tsx +108 -0
- package/src/primitives/index.ts +54 -0
- package/src/primitives/input.tsx +24 -0
- package/src/primitives/label.tsx +19 -0
- package/src/primitives/popover.tsx +31 -0
- package/src/primitives/select.tsx +158 -0
- package/src/primitives/table.tsx +91 -0
- package/src/primitives/time-picker.tsx +65 -0
- package/src/server/index.ts +3 -0
- package/src/server/types.ts +15 -0
- package/src/styles/globals.css +123 -0
- package/tailwind.config.ts +3 -0
- package/tests/components/TextField.test.tsx +94 -0
- package/tests/setup.ts +11 -0
- package/tsconfig.json +26 -0
- 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,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 "{listKey}" 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're trying to edit doesn't exist or you don'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
|
+
}
|