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} + + + + + + + ); +} 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';