added ImageUpload and other input types.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
|
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
|
||||||
|
import { UploadProvider } from "./providers/UploadProvider";
|
||||||
import AdminLayout from "./components/AdminLayout";
|
import AdminLayout from "./components/AdminLayout";
|
||||||
import ResourceView from "./components/ResourceView";
|
import ResourceView from "./components/ResourceView";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
@@ -69,7 +70,9 @@ export default function App() {
|
|||||||
<AppTheme>
|
<AppTheme>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
||||||
<AdminApp />
|
<UploadProvider>
|
||||||
|
<AdminApp />
|
||||||
|
</UploadProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AppTheme>
|
</AppTheme>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ResourceConfig, ResourceField } from '../types/config';
|
import { ResourceConfig, ResourceField } from '../types/config';
|
||||||
|
import { useUpload } from '../providers/UploadProvider';
|
||||||
|
import ImageUploadField from './fields/ImageUploadField';
|
||||||
|
|
||||||
interface GenericFormProps {
|
interface GenericFormProps {
|
||||||
config: ResourceConfig;
|
config: ResourceConfig;
|
||||||
@@ -27,9 +29,10 @@ export default function GenericForm({
|
|||||||
initialData = {},
|
initialData = {},
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
loading,
|
loading: saving,
|
||||||
}: GenericFormProps) {
|
}: GenericFormProps) {
|
||||||
const [formData, setFormData] = React.useState(initialData);
|
const [formData, setFormData] = React.useState(initialData);
|
||||||
|
const { uploadFile, uploading } = useUpload();
|
||||||
|
|
||||||
const handleChange = (key: string, value: any) => {
|
const handleChange = (key: string, value: any) => {
|
||||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||||
@@ -53,16 +56,18 @@ export default function GenericForm({
|
|||||||
name={key}
|
name={key}
|
||||||
field={field}
|
field={field}
|
||||||
value={formData[key]}
|
value={formData[key]}
|
||||||
onChange={(val) => handleChange(key, val)}
|
onChange={(val: any) => handleChange(key, val)}
|
||||||
disabled={field.readOnly}
|
disabled={field.readOnly}
|
||||||
|
uploadFile={uploadFile}
|
||||||
|
uploading={uploading}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||||
<Button variant="outlined" onClick={onCancel} disabled={loading}>
|
<Button variant="outlined" onClick={onCancel} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" type="submit" loading={loading} disabled={loading}>
|
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
|
||||||
Save {config.label}
|
Save {config.label}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -70,9 +75,23 @@ export default function GenericForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormField({ name, field, value, onChange, disabled }: any) {
|
function FormField({ name, field, value, onChange, disabled, uploadFile, uploading }: any) {
|
||||||
const label = field.label;
|
const label = field.label;
|
||||||
|
|
||||||
|
if (field.type === 'image') {
|
||||||
|
return (
|
||||||
|
<ImageUploadField
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onUpload={async (file) => {
|
||||||
|
const url = await uploadFile(file);
|
||||||
|
if (url) onChange(url);
|
||||||
|
}}
|
||||||
|
uploading={uploading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'boolean') {
|
if (field.type === 'boolean') {
|
||||||
return (
|
return (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -108,6 +127,36 @@ function FormField({ name, field, value, onChange, disabled }: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'datetime') {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
type="datetime-local"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={value ? new Date(value).toISOString().slice(0, 16) : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'date') {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
type="date"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={value ? new Date(value).toISOString().split('T')[0] : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'markdown' || field.type === 'string') {
|
if (field.type === 'markdown' || field.type === 'string') {
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
@@ -137,21 +186,6 @@ function FormField({ name, field, value, onChange, disabled }: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'date') {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
type="date"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
value={value ? new Date(value).toISOString().split('T')[0] : ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
56
src_generic/components/fields/ImageUploadField.tsx
Normal file
56
src_generic/components/fields/ImageUploadField.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
|
||||||
|
import { config } from "../../config";
|
||||||
|
|
||||||
|
interface ImageUploadFieldProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageUploadField({
|
||||||
|
label = "Upload Image",
|
||||||
|
value,
|
||||||
|
uploading = false,
|
||||||
|
onUpload,
|
||||||
|
size = 64,
|
||||||
|
}: ImageUploadFieldProps) {
|
||||||
|
|
||||||
|
const imgSrc = value
|
||||||
|
? config.baseUrl.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
value.replace(/^\/+/, "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mb: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<Avatar
|
||||||
|
src={imgSrc}
|
||||||
|
sx={{ width: size, height: size, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
disabled={uploading}
|
||||||
|
startIcon={uploading && <CircularProgress size={16} />}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading..." : "Choose File"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onUpload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const AccountResource: ResourceConfig = {
|
|||||||
},
|
},
|
||||||
currency: { type: "string", label: "Currency", required: true },
|
currency: { type: "string", label: "Currency", required: true },
|
||||||
is_active: { type: "boolean", label: "Active" },
|
is_active: { type: "boolean", label: "Active" },
|
||||||
|
avatar: { type: "image", label: "Account Icon" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ const ExpenseResource: ResourceConfig = {
|
|||||||
fields: {
|
fields: {
|
||||||
id: { type: "string", label: "ID", readOnly: true },
|
id: { type: "string", label: "ID", readOnly: true },
|
||||||
amount: { type: "number", label: "Amount", required: true },
|
amount: { type: "number", label: "Amount", required: true },
|
||||||
occurred_at: { type: "date", label: "Occurred At", required: true },
|
occurred_at: { type: "datetime", label: "Occurred At", required: true },
|
||||||
payee: {
|
payee: {
|
||||||
type: "object",
|
type: "object",
|
||||||
label: "Payee",
|
label: "Payee",
|
||||||
@@ -58,7 +59,7 @@ const ExpenseResource: ResourceConfig = {
|
|||||||
},
|
},
|
||||||
account: { type: "string", label: "Account ID", required: true },
|
account: { type: "string", label: "Account ID", required: true },
|
||||||
tags: { type: "array", label: "Tags" },
|
tags: { type: "array", label: "Tags" },
|
||||||
created_at: { type: "date", label: "Created At", readOnly: true },
|
created_at: { type: "datetime", label: "Created At", readOnly: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
52
src_generic/providers/UploadProvider.tsx
Normal file
52
src_generic/providers/UploadProvider.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { createContext, useContext, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
|
export interface UploadContextModel {
|
||||||
|
uploadFile: (file: File) => Promise<string | null>;
|
||||||
|
uploading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
|
||||||
|
|
||||||
|
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const uploadFile = async (file: File): Promise<string | null> => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const binary = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
const res = await api.post("/uploads", binary, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.url as string;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("File upload failed:", err);
|
||||||
|
setError(err.response?.data?.detail || "Failed to upload file");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UploadContext.Provider value={{ uploadFile, uploading, error }}>
|
||||||
|
{children}
|
||||||
|
</UploadContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpload = (): UploadContextModel => {
|
||||||
|
const ctx = useContext(UploadContext);
|
||||||
|
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -3,8 +3,10 @@ export type FieldType =
|
|||||||
| 'number'
|
| 'number'
|
||||||
| 'boolean'
|
| 'boolean'
|
||||||
| 'date'
|
| 'date'
|
||||||
|
| 'datetime'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
| 'enum'
|
| 'enum'
|
||||||
|
| 'image'
|
||||||
| 'object'
|
| 'object'
|
||||||
| 'array';
|
| 'array';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user