Skip to content

Commit 9eefaf6

Browse files
committed
Add client package with React dashboard
Introduces a new 'client' package containing a React-based dashboard application, including components for object management, authentication context, routing hooks, and UI utilities. Also adds related configuration files and updates dependencies to support React, TypeScript, Tailwind CSS, and ESLint for the new client package.
1 parent 90ae69e commit 9eefaf6

24 files changed

+2974
-41
lines changed

package-lock.json

Lines changed: 1858 additions & 41 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/client/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>ObjectQL</title>
8+
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
9+
</head>
10+
<body>
11+
<div id="root"></div>
12+
<script type="module" src="/src/main.tsx"></script>
13+
</body>
14+
</html>

packages/client/package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@objectql/client",
3+
"private": true,
4+
"version": "0.1.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc -b && vite build",
9+
"lint": "eslint .",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^18.3.1",
14+
"react-dom": "^18.3.1",
15+
"@objectql/ui": "*",
16+
"lucide-react": "^0.344.0",
17+
"clsx": "^2.1.0",
18+
"tailwind-merge": "^2.2.1"
19+
},
20+
"devDependencies": {
21+
"@eslint/js": "^9.17.0",
22+
"@types/react": "^18.3.18",
23+
"@types/react-dom": "^18.3.5",
24+
"@vitejs/plugin-react": "^4.3.4",
25+
"autoprefixer": "^10.4.20",
26+
"eslint": "^9.17.0",
27+
"eslint-plugin-react-hooks": "^5.0.0",
28+
"eslint-plugin-react-refresh": "^0.4.16",
29+
"globals": "^15.14.0",
30+
"postcss": "^8.4.49",
31+
"tailwindcss": "^3.4.17",
32+
"typescript": "~5.6.2",
33+
"typescript-eslint": "^8.18.2",
34+
"vite": "^6.0.5"
35+
}
36+
}

packages/client/postcss.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default {
2+
plugins: {
3+
tailwindcss: {},
4+
autoprefixer: {},
5+
},
6+
}

packages/client/src/App.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useState, useEffect } from 'react';
2+
import Login from './pages/Login';
3+
import Dashboard from './pages/Dashboard';
4+
import { AuthProvider, useAuth } from './context/AuthContext';
5+
6+
function AppContent() {
7+
const { user, loading } = useAuth();
8+
const [currentPath, setCurrentPath] = useState(window.location.pathname);
9+
10+
useEffect(() => {
11+
const handlePopState = () => setCurrentPath(window.location.pathname);
12+
window.addEventListener('popstate', handlePopState);
13+
return () => window.removeEventListener('popstate', handlePopState);
14+
}, []);
15+
16+
if (loading) {
17+
return (
18+
<div className="flex h-screen w-full items-center justify-center">
19+
<div className="w-6 h-6 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
20+
</div>
21+
);
22+
}
23+
24+
// Simple routing
25+
if (!user && currentPath !== '/login') {
26+
window.history.pushState({}, '', '/login');
27+
return <Login />;
28+
}
29+
30+
if (currentPath === '/login') {
31+
if (user) {
32+
window.history.pushState({}, '', '/dashboard');
33+
return <Dashboard />;
34+
}
35+
return <Login />;
36+
}
37+
38+
return <Dashboard />;
39+
}
40+
41+
function App() {
42+
return (
43+
<AuthProvider>
44+
<AppContent />
45+
</AuthProvider>
46+
);
47+
}
48+
49+
export default App;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useState, useEffect } from 'react';
2+
import { Button, Modal, Spinner, AutoForm } from '@objectql/ui';
3+
import { getHeaders } from '../../lib/api';
4+
5+
interface ObjectDetailViewProps {
6+
objectName: string;
7+
recordId: string;
8+
navigate: (path: string) => void;
9+
objectSchema: any;
10+
}
11+
12+
export function ObjectDetailView({ objectName, recordId, navigate, objectSchema }: ObjectDetailViewProps) {
13+
const [data, setData] = useState<any>(null);
14+
const [schema, setSchema] = useState<any>(null);
15+
const [isEditing, setIsEditing] = useState(false);
16+
const [loading, setLoading] = useState(true);
17+
18+
const label = objectSchema?.label || objectSchema?.title || objectName;
19+
20+
useEffect(() => {
21+
setLoading(true);
22+
Promise.all([
23+
fetch(`/api/object/${objectName}/${recordId}`, { headers: getHeaders() }).then(async r => {
24+
if (!r.ok) throw new Error("Failed to load record");
25+
return r.json();
26+
}),
27+
fetch(`/api/object/_schema/object/${objectName}`, { headers: getHeaders() }).then(r => r.json())
28+
]).then(([record, schemaData]) => {
29+
setData(record);
30+
setSchema(schemaData);
31+
}).catch(console.error)
32+
.finally(() => setLoading(false));
33+
}, [objectName, recordId]);
34+
35+
const handleDelete = () => {
36+
if (!confirm('Are you sure you want to delete this record?')) return;
37+
fetch(`/api/object/${objectName}/${recordId}`, {
38+
method: 'DELETE',
39+
headers: getHeaders()
40+
}).then(() => navigate(`/object/${objectName}`))
41+
.catch(e => alert(e.message));
42+
};
43+
44+
const handleUpdate = (formData: any) => {
45+
fetch(`/api/object/${objectName}/${recordId}`, {
46+
method: 'PUT',
47+
headers: getHeaders(),
48+
body: JSON.stringify(formData)
49+
}).then(async res => {
50+
if(!res.ok) throw new Error(await res.text());
51+
return res.json();
52+
}).then(() => {
53+
setIsEditing(false);
54+
// Reload data
55+
fetch(`/api/object/${objectName}/${recordId}`, { headers: getHeaders() })
56+
.then(r => r.json())
57+
.then(setData);
58+
}).catch(e => alert(e.message));
59+
};
60+
61+
if (loading) return (
62+
<div className="flex flex-col h-full bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden p-8 items-center justify-center">
63+
<Spinner className="w-6 h-6 text-gray-400" />
64+
</div>
65+
);
66+
67+
if (!data) return <div>Record not found</div>;
68+
69+
return (
70+
<div className="flex flex-col h-full bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden animate-[fadeIn_0.3s_ease-out]">
71+
{/* Header */}
72+
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-white">
73+
<div className="flex items-center gap-4">
74+
<button onClick={() => navigate(`/object/${objectName}`)} className="p-2 -ml-2 hover:bg-gray-100 rounded-full transition-colors text-gray-500">
75+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
76+
</button>
77+
<div>
78+
<div className="flex items-center gap-2 text-xs text-gray-500 uppercase font-medium tracking-wider mb-0.5">
79+
{label}
80+
<span className="text-gray-300">/</span>
81+
<span className="text-gray-400">{recordId}</span>
82+
</div>
83+
<h1 className="text-xl font-bold text-gray-900">{data.name || data.title || recordId}</h1>
84+
</div>
85+
</div>
86+
87+
<div className="flex gap-2">
88+
<Button variant="secondary" onClick={() => setIsEditing(true)} className="gap-2">
89+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
90+
Edit
91+
</Button>
92+
<Button variant="secondary" onClick={handleDelete} className="hover:bg-red-50 hover:text-red-600 gap-2 border-transparent">
93+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
94+
Delete
95+
</Button>
96+
</div>
97+
</div>
98+
99+
{/* Content */}
100+
<div className="flex-1 overflow-auto bg-gray-50/50 p-6">
101+
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 max-w-4xl mx-auto">
102+
{schema ? (
103+
<AutoForm
104+
schema={schema}
105+
initialValues={data}
106+
readonly={true}
107+
onSubmit={() => {}}
108+
/>
109+
) : (
110+
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
111+
{Object.entries(data).map(([key, value]) => (
112+
<div key={key} className="space-y-1.5 border-b border-gray-50 pb-2">
113+
<div className="text-xs font-medium text-gray-400 uppercase tracking-wide">{key}</div>
114+
<div className="text-sm text-gray-900 font-medium break-words">
115+
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '-')}
116+
</div>
117+
</div>
118+
))}
119+
</div>
120+
)}
121+
</div>
122+
</div>
123+
124+
{/* Edit Modal */}
125+
<Modal isOpen={isEditing} onClose={() => setIsEditing(false)} title={`Edit ${label}`}>
126+
<AutoForm
127+
schema={schema}
128+
initialValues={data}
129+
onSubmit={handleUpdate}
130+
onCancel={() => setIsEditing(false)}
131+
/>
132+
</Modal>
133+
</div>
134+
);
135+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useState, useEffect } from 'react';
2+
import { AutoForm, Spinner } from '@objectql/ui';
3+
4+
interface ObjectFormProps {
5+
objectName: string;
6+
initialValues: any;
7+
onSubmit: (data: any) => void;
8+
onCancel: () => void;
9+
headers?: Record<string, string>;
10+
}
11+
12+
export function ObjectForm({ objectName, initialValues, onSubmit, onCancel, headers }: ObjectFormProps) {
13+
const [schema, setSchema] = useState(null);
14+
15+
useEffect(() => {
16+
fetch(`/api/object/_schema/object/${objectName}`, { headers })
17+
.then(res => res.json())
18+
.then(setSchema)
19+
.catch(console.error);
20+
}, [objectName, headers]);
21+
22+
if (!schema) return <div className="p-8 flex justify-center"><Spinner className="w-6 h-6 text-gray-400" /></div>;
23+
24+
return (
25+
<AutoForm
26+
schema={schema}
27+
initialValues={initialValues}
28+
onSubmit={onSubmit}
29+
onCancel={onCancel}
30+
/>
31+
);
32+
}

0 commit comments

Comments
 (0)