added ImageUpload and other input types.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
|
||||
import { UploadProvider } from "./providers/UploadProvider";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import ResourceView from "./components/ResourceView";
|
||||
import { config } from "./config";
|
||||
@@ -69,7 +70,9 @@ export default function App() {
|
||||
<AppTheme>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
||||
<AdminApp />
|
||||
<UploadProvider>
|
||||
<AdminApp />
|
||||
</UploadProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</AppTheme>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { ResourceConfig, ResourceField } from '../types/config';
|
||||
import { useUpload } from '../providers/UploadProvider';
|
||||
import ImageUploadField from './fields/ImageUploadField';
|
||||
|
||||
interface GenericFormProps {
|
||||
config: ResourceConfig;
|
||||
@@ -27,9 +29,10 @@ export default function GenericForm({
|
||||
initialData = {},
|
||||
onSave,
|
||||
onCancel,
|
||||
loading,
|
||||
loading: saving,
|
||||
}: GenericFormProps) {
|
||||
const [formData, setFormData] = React.useState(initialData);
|
||||
const { uploadFile, uploading } = useUpload();
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
@@ -53,16 +56,18 @@ export default function GenericForm({
|
||||
name={key}
|
||||
field={field}
|
||||
value={formData[key]}
|
||||
onChange={(val) => handleChange(key, val)}
|
||||
onChange={(val: any) => handleChange(key, val)}
|
||||
disabled={field.readOnly}
|
||||
uploadFile={uploadFile}
|
||||
uploading={uploading}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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
|
||||
</Button>
|
||||
<Button variant="contained" type="submit" loading={loading} disabled={loading}>
|
||||
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
|
||||
Save {config.label}
|
||||
</Button>
|
||||
</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;
|
||||
|
||||
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') {
|
||||
return (
|
||||
<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') {
|
||||
return (
|
||||
<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 (
|
||||
<TextField
|
||||
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 },
|
||||
is_active: { type: "boolean", label: "Active" },
|
||||
avatar: { type: "image", label: "Account Icon" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,7 +43,7 @@ const ExpenseResource: ResourceConfig = {
|
||||
fields: {
|
||||
id: { type: "string", label: "ID", readOnly: 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: {
|
||||
type: "object",
|
||||
label: "Payee",
|
||||
@@ -58,7 +59,7 @@ const ExpenseResource: ResourceConfig = {
|
||||
},
|
||||
account: { type: "string", label: "Account ID", required: true },
|
||||
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'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'markdown'
|
||||
| 'enum'
|
||||
| 'image'
|
||||
| 'object'
|
||||
| 'array';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user