diff --git a/src_generic/App.tsx b/src_generic/App.tsx index 5248c46..fff325e 100644 --- a/src_generic/App.tsx +++ b/src_generic/App.tsx @@ -1,24 +1,41 @@ -import * as React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +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"; -import { Box, Typography, Paper } from '@mui/material'; +import { getAppConfig } from "./config"; +import { initializeApiClients } from "./api/client"; +import { AppConfig } from "./types/config"; +import { Box, Typography, Paper, CircularProgress } from "@mui/material"; import AppTheme from "../src/shared-theme/AppTheme"; const queryClient = new QueryClient(); +// Create a context for the app config +export const ConfigContext = React.createContext(null); + function Dashboard() { + const config = React.useContext(ConfigContext); return ( - Welcome to the Admin Panel - Select a resource from the sidebar to manage data. - - - {config.resources.map(res => ( - + + Welcome to the Admin Panel + + + Select a resource from the sidebar to manage data. + + + + {config?.resources.map((res) => ( + {res.pluralLabel} ))} @@ -29,32 +46,37 @@ function Dashboard() { function AdminApp() { const { currentUser, login, logout, loading, error } = useAuth(); - const [selectedResourceName, setSelectedResourceName] = React.useState(null); + const config = React.useContext(ConfigContext); + const [selectedResourceName, setSelectedResourceName] = React.useState< + string | null + >(null); if (!currentUser) { return ( { - }} // Disable registration for Admin + register={async () => {}} // Disable registration for Admin loading={loading} error={error} - onSwitchMode={() => { - }} onBack={function (): void { - throw new Error("Function not implemented."); - }} currentUser={undefined} /> + onSwitchMode={() => {}} + onBack={() => {}} + currentUser={null} + /> ); } - const selectedResource = config.resources.find(r => r.name === selectedResourceName); + const selectedResource = config?.resources.find( + (r) => r.name === selectedResourceName + ); return ( - {selectedResource ? ( @@ -66,14 +88,42 @@ function AdminApp() { } export default function App() { + const [config, setConfig] = React.useState(null); + + React.useEffect(() => { + getAppConfig().then((cfg) => { + initializeApiClients(cfg.baseUrl, cfg.authBaseUrl); + setConfig(cfg); + }); + }, []); + + if (!config) { + return ( + + + + + + ); + } + return ( - - - - - + + + + + + + ); diff --git a/src_generic/api/client.ts b/src_generic/api/client.ts index 2e54750..d2905f9 100644 --- a/src_generic/api/client.ts +++ b/src_generic/api/client.ts @@ -1,5 +1,43 @@ +import axios, { AxiosInstance } from "axios"; import { createApiClient } from "../../auth/src"; -import { config } from "../config"; -export const api = createApiClient(config.baseUrl); -export const auth = createApiClient(config.authBaseUrl); +/** + * We expose a singleton-like getter/setter for the API clients + */ +let _api: AxiosInstance | null = null; +let _auth: AxiosInstance | null = null; + +export const api = { + get: (...args: Parameters) => { + if (!_api) throw new Error("API client not initialized"); + return _api.get(...args); + }, + post: (...args: Parameters) => { + if (!_api) throw new Error("API client not initialized"); + return _api.post(...args); + }, + put: (...args: Parameters) => { + if (!_api) throw new Error("API client not initialized"); + return _api.put(...args); + }, + delete: (...args: Parameters) => { + if (!_api) throw new Error("API client not initialized"); + return _api.delete(...args); + }, +}; + +export const auth = { + post: (...args: Parameters) => { + if (!_auth) throw new Error("Auth client not initialized"); + return _auth.post(...args); + }, + get: (...args: Parameters) => { + if (!_auth) throw new Error("Auth client not initialized"); + return _auth.get(...args); + }, +}; + +export function initializeApiClients(baseUrl: string, authBaseUrl: string) { + _api = createApiClient(baseUrl); + _auth = createApiClient(authBaseUrl); +} diff --git a/src_generic/components/AdminLayout.tsx b/src_generic/components/AdminLayout.tsx index ecad852..c2dece2 100644 --- a/src_generic/components/AdminLayout.tsx +++ b/src_generic/components/AdminLayout.tsx @@ -17,7 +17,7 @@ import { import TableViewIcon from '@mui/icons-material/TableView'; import DashboardIcon from '@mui/icons-material/Dashboard'; import LogoutIcon from '@mui/icons-material/Logout'; -import { config } from '../config'; +import { ResourceConfig } from '../types/config'; const drawerWidth = 240; @@ -27,6 +27,7 @@ interface AdminLayoutProps { selectedResourceName: string | null; onLogout: () => void; username?: string; + resources: ResourceConfig[]; } export default function AdminLayout({ @@ -34,7 +35,8 @@ export default function AdminLayout({ onSelectResource, selectedResourceName, onLogout, - username + username, + resources, }: AdminLayoutProps) { return ( @@ -77,7 +79,7 @@ export default function AdminLayout({ - {config.resources.map((res) => ( + {resources.map((res) => ( { setFormData((prev: any) => ({ ...prev, [key]: value })); @@ -60,6 +63,7 @@ export default function GenericForm({ disabled={field.readOnly} uploadFile={uploadFile} uploading={uploading} + baseUrl={appConfig?.baseUrl || ""} /> ))} @@ -75,7 +79,7 @@ export default function GenericForm({ ); } -function FormField({ name, field, value, onChange, disabled, uploadFile, uploading }: any) { +function FormField({ name, field, value, onChange, disabled, uploadFile, uploading, baseUrl }: any) { const label = field.label; if (field.type === 'image') { @@ -83,11 +87,12 @@ function FormField({ name, field, value, onChange, disabled, uploadFile, uploadi { + onUpload={async (file: any) => { const url = await uploadFile(file); if (url) onChange(url); }} uploading={uploading} + baseUrl={baseUrl} /> ); } diff --git a/src_generic/components/fields/ImageUploadField.tsx b/src_generic/components/fields/ImageUploadField.tsx index a652500..4ad18fd 100644 --- a/src_generic/components/fields/ImageUploadField.tsx +++ b/src_generic/components/fields/ImageUploadField.tsx @@ -1,6 +1,4 @@ -import * as React from "react"; import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material"; -import { config } from "../../config"; interface ImageUploadFieldProps { label?: string; @@ -8,6 +6,7 @@ interface ImageUploadFieldProps { uploading?: boolean; onUpload: (file: File) => void; size?: number; + baseUrl: string; } export default function ImageUploadField({ @@ -16,10 +15,11 @@ export default function ImageUploadField({ uploading = false, onUpload, size = 64, + baseUrl, }: ImageUploadFieldProps) { const imgSrc = value - ? config.baseUrl.replace(/\/+$/, "") + + ? baseUrl.replace(/\/+$/, "") + "/" + value.replace(/^\/+/, "") : ""; diff --git a/src_generic/config.ts b/src_generic/config.ts index 0429e17..66d3557 100644 --- a/src_generic/config.ts +++ b/src_generic/config.ts @@ -1,70 +1,14 @@ -import { AppConfig, ResourceConfig } from "./types/config"; +import { AppConfig } from "./types/config"; +import { loadConfigFromOpenApi } from "./utils/openapi_loader"; -const AccountResource: ResourceConfig = { - name: "accounts", - label: "Account", - pluralLabel: "Accounts", - endpoint: "/accounts", - primaryKey: "id", - fields: { - id: { type: "string", label: "ID", readOnly: true }, - name: { type: "string", label: "Name", required: true }, - type: { - type: "enum", - label: "Type", - required: true, - options: ["cash", "bank", "credit_card", "wallet", "other"], - }, - currency: { type: "string", label: "Currency", required: true }, - is_active: { type: "boolean", label: "Active" }, - avatar: { type: "image", label: "Account Icon" }, - }, -}; +export async function getAppConfig(): Promise { + const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000" + const config = await loadConfigFromOpenApi(baseUrl); -const TagResource: ResourceConfig = { - name: "tags", - label: "Tag", - pluralLabel: "Tags", - endpoint: "/tags", - primaryKey: "id", - fields: { - id: { type: "string", label: "ID", readOnly: true }, - name: { type: "string", label: "Name", required: true }, - parent_id: { type: "string", label: "Parent ID" }, - }, -}; - -const ExpenseResource: ResourceConfig = { - name: "expenses", - label: "Expense", - pluralLabel: "Expenses", - endpoint: "/expenses", - primaryKey: "id", - fields: { - id: { type: "string", label: "ID", readOnly: true }, - amount: { type: "number", label: "Amount", required: true }, - occurred_at: { type: "datetime", label: "Occurred At", required: true }, - payee: { - type: "object", - label: "Payee", - required: true, - schema: { - name: { type: "string", label: "Name", required: true }, - type: { - type: "enum", - label: "Type", - options: ["merchant", "person", "transfer", "other"], - }, - }, - }, - account: { type: "string", label: "Account ID", required: true }, - tags: { type: "array", label: "Tags" }, - created_at: { type: "datetime", label: "Created At", readOnly: true }, - }, -}; - -export const config: AppConfig = { - baseUrl: import.meta.env.VITE_API_BASE_URL || "http://localhost:8000", - authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "http://localhost:8001", - resources: [ExpenseResource, AccountResource, TagResource], -}; + // You can still apply overrides here + return { + ...config, + authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "http://localhost:8001", + baseUrl: import.meta.env.VITE_API_BASE_URL || config.baseUrl, + }; +} diff --git a/src_generic/api/expenses_openapi.yaml b/src_generic/expenses_openapi.yaml similarity index 100% rename from src_generic/api/expenses_openapi.yaml rename to src_generic/expenses_openapi.yaml diff --git a/src_generic/main.tsx b/src_generic/main.tsx index 080ad13..5ba2802 100644 --- a/src_generic/main.tsx +++ b/src_generic/main.tsx @@ -1,7 +1,13 @@ import * as React from 'react'; import { createRoot } from 'react-dom/client'; +import { Buffer } from 'buffer'; +import process from 'process'; import App from './App'; +// Polyfill Node.js globals for browser environment (needed by SwaggerParser) +window.Buffer = Buffer; +window.process = process; + const rootElement = document.getElementById('root'); const root = createRoot(rootElement!); diff --git a/src_generic/utils/openapi_loader.ts b/src_generic/utils/openapi_loader.ts new file mode 100644 index 0000000..4791e53 --- /dev/null +++ b/src_generic/utils/openapi_loader.ts @@ -0,0 +1,117 @@ +import SwaggerParser from "@apidevtools/swagger-parser"; +import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config"; + +/** + * Maps OpenAPI property types to our internal FieldType + */ +function mapOpenApiType(prop: any): FieldType { + const type = prop.type; + const format = prop.format; + + if (format === "date-time") return "datetime"; + if (format === "date") return "date"; + if (prop.enum) return "enum"; + if (type === "string" && (prop.description?.toLowerCase().includes("image") || prop.name?.toLowerCase().includes("icon"))) return "image"; + + switch (type) { + case "integer": + case "number": return "number"; + case "boolean": return "boolean"; + case "object": return "object"; + case "array": return "array"; + default: return "string"; + } +} + +/** + * Recursively converts OpenAPI schemas to ResourceField map + */ +function parseSchemaFields(schema: any): Record { + const fields: Record = {}; + const properties = schema.properties || {}; + const required = schema.required || []; + + for (const [key, prop] of Object.entries(properties) as any) { + fields[key] = { + type: mapOpenApiType(prop), + label: prop.title || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "), + required: required.includes(key), + options: prop.enum, + readOnly: prop.readOnly || key === "id" || key === "created_at" || key === "updated_at", + }; + + if (fields[key].type === "object" && prop.properties) { + fields[key].schema = parseSchemaFields(prop); + } + } + + return fields; +} + +/** + * Scans paths to identify resources and their basic configuration + */ +export async function loadConfigFromOpenApi(baseUrl: string): Promise { + // 1. Parse and dereference the spec (handles all $ref) + const api = await SwaggerParser.dereference( + new URL("/openapi.json", baseUrl).href + ); + + const resources: ResourceConfig[] = []; + const paths = api.paths || {}; + + // Group paths by base resource name (e.g., /expenses, /expenses/{id} -> expenses) + const resourcePaths: Record = {}; + + for (const path of Object.keys(paths)) { + const base = path.split("/")[1]; + if (!base) continue; + + if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] }; + const methods = Object.keys(paths[path] || {}); + resourcePaths[base].methods.push(...methods); + + // We prefer the plural GET path for schema extraction + if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) { + resourcePaths[base].listPath = path; + } + } + + // Generate ResourceConfig for each identified base path + for (const [name, info] of Object.entries(resourcePaths)) { + const listPath = info.listPath || `/${name}`; + const listOp = paths[listPath]?.get; + if (!listOp) continue; + + // Use common naming conventions or metadata from the spec + const label = name.charAt(0).toUpperCase() + name.slice(1, -1); // naive singularization + const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1); + + // Extract schema from the 200 response of the list endpoint + let schema: any = null; + const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema; + + if (responseSchema?.type === "array" && responseSchema.items) { + schema = responseSchema.items; + } else { + schema = responseSchema; + } + + if (schema) { + resources.push({ + name, + label: schema.title || label, + pluralLabel: pluralLabel, + endpoint: listPath, + primaryKey: "id", // assume 'id' as default or look for 'required' + 'unique' + fields: parseSchemaFields(schema), + }); + } + } + + return { + baseUrl: import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? ""), + authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "", + resources, + }; +}