diff --git a/src_generic/App.tsx b/src_generic/App.tsx
index 3492c50..5248c46 100644
--- a/src_generic/App.tsx
+++ b/src_generic/App.tsx
@@ -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() {
-
+
+
+
diff --git a/src_generic/components/GenericForm.tsx b/src_generic/components/GenericForm.tsx
index 364b5e4..da34e57 100644
--- a/src_generic/components/GenericForm.tsx
+++ b/src_generic/components/GenericForm.tsx
@@ -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}
/>
))}
-
@@ -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 (
+ {
+ const url = await uploadFile(file);
+ if (url) onChange(url);
+ }}
+ uploading={uploading}
+ />
+ );
+ }
+
if (field.type === 'boolean') {
return (
onChange(e.target.value)}
+ disabled={disabled}
+ required={field.required}
+ />
+ );
+ }
+
+ if (field.type === 'date') {
+ return (
+ onChange(e.target.value)}
+ disabled={disabled}
+ required={field.required}
+ />
+ );
+ }
+
if (field.type === 'markdown' || field.type === 'string') {
return (
onChange(e.target.value)}
- disabled={disabled}
- required={field.required}
- />
- );
- }
-
return (
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 (
+
+ {label}
+
+
+
+ }
+ >
+ {uploading ? "Uploading..." : "Choose File"}
+ {
+ const file = e.target.files?.[0];
+ if (file) onUpload(file);
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src_generic/config.ts b/src_generic/config.ts
index ed970f8..0429e17 100644
--- a/src_generic/config.ts
+++ b/src_generic/config.ts
@@ -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 },
},
};
diff --git a/src_generic/providers/UploadProvider.tsx b/src_generic/providers/UploadProvider.tsx
new file mode 100644
index 0000000..ed73a47
--- /dev/null
+++ b/src_generic/providers/UploadProvider.tsx
@@ -0,0 +1,52 @@
+import React, { createContext, useContext, useState } from "react";
+import { api } from "../api/client";
+
+export interface UploadContextModel {
+ uploadFile: (file: File) => Promise;
+ uploading: boolean;
+ error: string | null;
+}
+
+const UploadContext = createContext(undefined);
+
+export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const uploadFile = async (file: File): Promise => {
+ 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 (
+
+ {children}
+
+ );
+};
+
+export const useUpload = (): UploadContextModel => {
+ const ctx = useContext(UploadContext);
+ if (!ctx) throw new Error("useUpload must be used within UploadProvider");
+ return ctx;
+};
diff --git a/src_generic/types/config.ts b/src_generic/types/config.ts
index e66563d..62a9f4a 100644
--- a/src_generic/types/config.ts
+++ b/src_generic/types/config.ts
@@ -3,8 +3,10 @@ export type FieldType =
| 'number'
| 'boolean'
| 'date'
+ | 'datetime'
| 'markdown'
| 'enum'
+ | 'image'
| 'object'
| 'array';