diff --git a/package-lock.json b/package-lock.json
index 363645c..4ccc006 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,18 @@
{
"name": "aetoskia-blog-app",
- "version": "0.3.1",
+ "version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aetoskia-blog-app",
- "version": "0.2.1",
+ "version": "0.3.2",
"dependencies": {
"@emotion/react": "latest",
"@emotion/styled": "latest",
"@mui/icons-material": "latest",
"@mui/material": "latest",
+ "@tanstack/react-query": "^5.96.1",
"axios": "latest",
"markdown-to-jsx": "latest",
"marked": "latest",
@@ -1408,6 +1409,30 @@
"win32"
]
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.96.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.1.tgz",
+ "integrity": "sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.96.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.1.tgz",
+ "integrity": "sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==",
+ "dependencies": {
+ "@tanstack/query-core": "5.96.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
diff --git a/package.json b/package.json
index 20c7056..e8460c1 100644
--- a/package.json
+++ b/package.json
@@ -10,15 +10,16 @@
"dependencies": {
"@emotion/react": "latest",
"@emotion/styled": "latest",
- "@mui/material": "latest",
"@mui/icons-material": "latest",
+ "@mui/material": "latest",
+ "@tanstack/react-query": "^5.96.1",
+ "axios": "latest",
+ "markdown-to-jsx": "latest",
+ "marked": "latest",
"react": "latest",
"react-dom": "latest",
"react-markdown": "latest",
- "markdown-to-jsx": "latest",
- "remark-gfm": "latest",
- "marked": "latest",
- "axios": "latest"
+ "remark-gfm": "latest"
},
"devDependencies": {
"@vitejs/plugin-react": "latest",
diff --git a/src_generic/App.tsx b/src_generic/App.tsx
new file mode 100644
index 0000000..3492c50
--- /dev/null
+++ b/src_generic/App.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { AuthProvider, useAuth, AuthPage } from "../auth/src";
+import AdminLayout from "./components/AdminLayout";
+import ResourceView from "./components/ResourceView";
+import { config } from "./config";
+import { Box, Typography, Paper } from '@mui/material';
+import AppTheme from "../src/shared-theme/AppTheme";
+
+const queryClient = new QueryClient();
+
+function Dashboard() {
+ return (
+
+ Welcome to the Admin Panel
+ Select a resource from the sidebar to manage data.
+
+
+ {config.resources.map(res => (
+
+ {res.pluralLabel}
+
+ ))}
+
+
+ );
+}
+
+function AdminApp() {
+ const { currentUser, login, logout, loading, error } = useAuth();
+ const [selectedResourceName, setSelectedResourceName] = React.useState(null);
+
+ if (!currentUser) {
+ return (
+ {
+ }} // Disable registration for Admin
+ loading={loading}
+ error={error}
+ onSwitchMode={() => {
+ }} onBack={function (): void {
+ throw new Error("Function not implemented.");
+ }} currentUser={undefined} />
+ );
+ }
+
+ const selectedResource = config.resources.find(r => r.name === selectedResourceName);
+
+ return (
+
+ {selectedResource ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src_generic/api/client.ts b/src_generic/api/client.ts
new file mode 100644
index 0000000..2e54750
--- /dev/null
+++ b/src_generic/api/client.ts
@@ -0,0 +1,5 @@
+import { createApiClient } from "../../auth/src";
+import { config } from "../config";
+
+export const api = createApiClient(config.baseUrl);
+export const auth = createApiClient(config.authBaseUrl);
diff --git a/src_generic/api/expenses_openapi.yaml b/src_generic/api/expenses_openapi.yaml
new file mode 100644
index 0000000..f9478a9
--- /dev/null
+++ b/src_generic/api/expenses_openapi.yaml
@@ -0,0 +1,391 @@
+openapi: 3.0.3
+info:
+ title: Expenses Service
+ version: "1.0.0"
+ description: OpenAPI-first service for tracking expenses and cashflow.
+
+paths:
+ /expenses:
+ post:
+ operationId: create_expense
+ summary: Create a new expense or income entry
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExpenseInput"
+ responses:
+ "201":
+ description: Expense created
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Expense"
+ get:
+ operationId: list_expenses
+ summary: List expenses
+ parameters:
+ - name: from
+ in: query
+ schema:
+ type: string
+ format: date-time
+ - name: to
+ in: query
+ schema:
+ type: string
+ format: date-time
+ responses:
+ "200":
+ description: List of expenses
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/Expense"
+
+ /expenses/{expense_id}:
+ get:
+ operationId: get_expense
+ summary: Get expense by ID
+ parameters:
+ - name: expense_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Expense found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Expense"
+ "404":
+ description: Expense not found
+
+ /accounts:
+ post:
+ operationId: create_account
+ summary: Create an account
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AccountCreate"
+ responses:
+ "201":
+ description: Account created
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Account"
+ get:
+ operationId: list_accounts
+ summary: List accounts
+ responses:
+ "200":
+ description: List of accounts
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/Account"
+
+ /accounts/{account_id}:
+ get:
+ operationId: get_account
+ summary: Get account by ID
+ parameters:
+ - name: account_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Account found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Account"
+ "404":
+ description: Account not found
+ put:
+ operationId: update_account
+ summary: Update an account
+ parameters:
+ - name: account_id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AccountUpdate"
+ responses:
+ "200":
+ description: Account updated
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Account"
+ "404":
+ description: Account not found
+ delete:
+ operationId: delete_account
+ summary: Delete an account
+ parameters:
+ - name: account_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "204":
+ description: Account deleted
+ "404":
+ description: Account not found
+
+ /tags:
+ post:
+ operationId: create_tag
+ summary: Create a tag
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TagCreate"
+ responses:
+ "201":
+ description: Tag created
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Tag"
+ get:
+ operationId: list_tags
+ summary: List tags
+ responses:
+ "200":
+ description: List of tags
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/Tag"
+
+ /tags/{tag_id}:
+ get:
+ operationId: get_tag
+ summary: Get tag by ID
+ parameters:
+ - name: tag_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Tag found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Tag"
+ "404":
+ description: Tag not found
+ put:
+ operationId: update_tag
+ summary: Update a tag
+ parameters:
+ - name: tag_id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TagUpdate"
+ responses:
+ "200":
+ description: Tag updated
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Tag"
+ "404":
+ description: Tag not found
+ delete:
+ operationId: delete_tag
+ summary: Delete a tag
+ parameters:
+ - name: tag_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "204":
+ description: Tag deleted
+ "404":
+ description: Tag not found
+
+components:
+ schemas:
+ PublicUser:
+ type: object
+ required: [id, username]
+ properties:
+ id:
+ type: string
+ username:
+ type: string
+ email:
+ type: string
+ format: email
+
+ PayeeType:
+ type: string
+ enum: [merchant, person, transfer, other]
+
+ Payee:
+ type: object
+ required: [type, name]
+ properties:
+ type:
+ $ref: "#/components/schemas/PayeeType"
+ name:
+ type: string
+
+ AccountType:
+ type: string
+ enum: [cash, bank, credit_card, wallet, other]
+
+ Account:
+ type: object
+ required: [id, name, type, currency]
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ type:
+ $ref: "#/components/schemas/AccountType"
+ currency:
+ type: string
+ is_active:
+ type: boolean
+
+ AccountCreate:
+ type: object
+ required: [name, type, currency]
+ properties:
+ name:
+ type: string
+ type:
+ $ref: "#/components/schemas/AccountType"
+ currency:
+ type: string
+ is_active:
+ type: boolean
+
+ AccountUpdate:
+ allOf:
+ - $ref: "#/components/schemas/AccountCreate"
+
+ Tag:
+ type: object
+ required: [id, name]
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ parent_id:
+ type: string
+ nullable: true
+
+ TagCreate:
+ type: object
+ required: [name]
+ properties:
+ name:
+ type: string
+ parent_id:
+ type: string
+ nullable: true
+
+ TagUpdate:
+ allOf:
+ - $ref: "#/components/schemas/TagCreate"
+
+ ExpenseInput:
+ type: object
+ required:
+ - payor
+ - payee
+ - amount
+ - account
+ - occurred_at
+ properties:
+ payor:
+ type: string
+ payee:
+ $ref: "#/components/schemas/Payee"
+ amount:
+ type: number
+ format: float
+ account:
+ type: string
+ tags:
+ type: array
+ items:
+ type: string
+ occurred_at:
+ type: string
+ format: date-time
+
+ Expense:
+ type: object
+ required:
+ - id
+ - payor
+ - payee
+ - amount
+ - account
+ - occurred_at
+ - created_at
+ properties:
+ id:
+ type: string
+ payor:
+ $ref: "#/components/schemas/PublicUser"
+ payee:
+ $ref: "#/components/schemas/Payee"
+ amount:
+ type: number
+ format: float
+ account:
+ $ref: "#/components/schemas/Account"
+ tags:
+ type: array
+ items:
+ $ref: "#/components/schemas/Tag"
+ occurred_at:
+ type: string
+ format: date-time
+ created_at:
+ type: string
+ format: date-time
diff --git a/src_generic/components/AdminLayout.tsx b/src_generic/components/AdminLayout.tsx
new file mode 100644
index 0000000..ecad852
--- /dev/null
+++ b/src_generic/components/AdminLayout.tsx
@@ -0,0 +1,103 @@
+import * as React from 'react';
+import {
+ Box,
+ Drawer,
+ AppBar,
+ Toolbar,
+ List,
+ Typography,
+ Divider,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ CssBaseline,
+ IconButton
+} from '@mui/material';
+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';
+
+const drawerWidth = 240;
+
+interface AdminLayoutProps {
+ children: React.ReactNode;
+ onSelectResource: (resourceName: string | null) => void;
+ selectedResourceName: string | null;
+ onLogout: () => void;
+ username?: string;
+}
+
+export default function AdminLayout({
+ children,
+ onSelectResource,
+ selectedResourceName,
+ onLogout,
+ username
+}: AdminLayoutProps) {
+ return (
+
+
+ theme.zIndex.drawer + 1 }}>
+
+
+ Admin Panel
+
+
+ {username}
+
+
+
+
+
+
+
+
+
+
+
+ onSelectResource(null)}
+ >
+
+
+
+
+
+
+
+
+
+ {config.resources.map((res) => (
+
+ onSelectResource(res.name)}
+ >
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src_generic/components/GenericForm.tsx b/src_generic/components/GenericForm.tsx
new file mode 100644
index 0000000..364b5e4
--- /dev/null
+++ b/src_generic/components/GenericForm.tsx
@@ -0,0 +1,163 @@
+import * as React from 'react';
+import {
+ Box,
+ TextField,
+ Button,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ FormControlLabel,
+ Checkbox,
+ Typography,
+ Divider,
+} from '@mui/material';
+import { ResourceConfig, ResourceField } from '../types/config';
+
+interface GenericFormProps {
+ config: ResourceConfig;
+ initialData?: any;
+ onSave: (data: any) => Promise;
+ onCancel: () => void;
+ loading?: boolean;
+}
+
+export default function GenericForm({
+ config,
+ initialData = {},
+ onSave,
+ onCancel,
+ loading,
+}: GenericFormProps) {
+ const [formData, setFormData] = React.useState(initialData);
+
+ const handleChange = (key: string, value: any) => {
+ setFormData((prev: any) => ({ ...prev, [key]: value }));
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSave(formData);
+ };
+
+ return (
+
+
+ {initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`}
+
+
+
+ {Object.entries(config.fields).map(([key, field]) => (
+ handleChange(key, val)}
+ disabled={field.readOnly}
+ />
+ ))}
+
+
+
+
+
+
+ );
+}
+
+function FormField({ name, field, value, onChange, disabled }: any) {
+ const label = field.label;
+
+ if (field.type === 'boolean') {
+ return (
+ onChange(e.target.checked)}
+ disabled={disabled}
+ />
+ }
+ label={label}
+ />
+ );
+ }
+
+ if (field.type === 'enum' && field.options) {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ if (field.type === 'markdown' || field.type === 'string') {
+ return (
+ onChange(e.target.value)}
+ disabled={disabled}
+ required={field.required}
+ />
+ );
+ }
+
+ if (field.type === 'number') {
+ return (
+ onChange(Number(e.target.value))}
+ disabled={disabled}
+ required={field.required}
+ />
+ );
+ }
+
+ if (field.type === 'date') {
+ return (
+ onChange(e.target.value)}
+ disabled={disabled}
+ required={field.required}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src_generic/components/GenericTable.tsx b/src_generic/components/GenericTable.tsx
new file mode 100644
index 0000000..410e24c
--- /dev/null
+++ b/src_generic/components/GenericTable.tsx
@@ -0,0 +1,95 @@
+import * as React from 'react';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ IconButton,
+ Typography,
+ Box,
+ Button
+} from '@mui/material';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/Delete';
+import { ResourceConfig } from '../types/config';
+
+interface GenericTableProps {
+ config: ResourceConfig;
+ data: any[];
+ onEdit: (item: any) => void;
+ onDelete: (id: string) => void;
+ onCreate: () => void;
+}
+
+export default function GenericTable({
+ config,
+ data,
+ onEdit,
+ onDelete,
+ onCreate,
+}: GenericTableProps) {
+ const fields = Object.entries(config.fields);
+
+ return (
+
+
+ {config.pluralLabel}
+
+
+
+
+
+
+
+ {fields.map(([key, field]) => (
+ {field.label}
+ ))}
+ Actions
+
+
+
+ {data.map((item) => (
+
+ {fields.map(([key, field]) => (
+
+ {renderCellValue(item[key], field)}
+
+ ))}
+
+ onEdit(item)}>
+
+
+ onDelete(item[config.primaryKey])}>
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function renderCellValue(value: any, field: any) {
+ if (value === null || value === undefined) return '-';
+
+ switch (field.type) {
+ case 'boolean':
+ return value ? 'Yes' : 'No';
+ case 'date':
+ return new Date(value).toLocaleDateString();
+ case 'object':
+ return JSON.stringify(value);
+ case 'array':
+ return `${value.length} items`;
+ default:
+ return String(value);
+ }
+}
diff --git a/src_generic/components/ResourceView.tsx b/src_generic/components/ResourceView.tsx
new file mode 100644
index 0000000..94e09de
--- /dev/null
+++ b/src_generic/components/ResourceView.tsx
@@ -0,0 +1,79 @@
+import * as React from 'react';
+import { Box, Typography, Button, Paper, CircularProgress } from '@mui/material';
+import { ResourceConfig } from '../types/config';
+import { useResource } from '../hooks/useResource';
+import GenericTable from './GenericTable';
+import GenericForm from './GenericForm';
+
+interface ResourceViewProps {
+ config: ResourceConfig;
+}
+
+export default function ResourceView({ config }: ResourceViewProps) {
+ const [view, setView] = React.useState<'list' | 'create' | 'edit'>('list');
+ const [selectedItem, setSelectedItem] = React.useState(null);
+
+ const { useList, useCreate, useUpdate, useDelete } = useResource(config);
+
+ const { data, isLoading, error } = useList();
+ const createMutation = useCreate();
+ const updateMutation = useUpdate();
+ const deleteMutation = useDelete();
+
+ const handleEdit = (item: any) => {
+ setSelectedItem(item);
+ setView('edit');
+ };
+
+ const handleCreate = () => {
+ setSelectedItem(null);
+ setView('create');
+ };
+
+ const handleSave = async (formData: any) => {
+ try {
+ if (view === 'edit') {
+ const id = formData[config.primaryKey];
+ await updateMutation.mutateAsync({ id, data: formData });
+ } else {
+ await createMutation.mutateAsync(formData);
+ }
+ setView('list');
+ } catch (err) {
+ console.error('Save failed:', err);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ if (window.confirm('Are you sure you want to delete this item?')) {
+ await deleteMutation.mutateAsync(id);
+ }
+ };
+
+ if (isLoading) return ;
+ if (error) return Error loading {config.pluralLabel};
+
+ return (
+
+ {view === 'list' ? (
+
+ ) : (
+
+ setView('list')}
+ loading={createMutation.isPending || updateMutation.isPending}
+ />
+
+ )}
+
+ );
+}
diff --git a/src_generic/config.ts b/src_generic/config.ts
new file mode 100644
index 0000000..ed970f8
--- /dev/null
+++ b/src_generic/config.ts
@@ -0,0 +1,69 @@
+import { AppConfig, ResourceConfig } from "./types/config";
+
+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" },
+ },
+};
+
+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: "date", 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: "date", 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],
+};
diff --git a/src_generic/hooks/useResource.ts b/src_generic/hooks/useResource.ts
new file mode 100644
index 0000000..223aac9
--- /dev/null
+++ b/src_generic/hooks/useResource.ts
@@ -0,0 +1,77 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "../api/client";
+import { ResourceConfig } from "../types/config";
+
+export function useResource(config: ResourceConfig) {
+ const queryClient = useQueryClient();
+ const { name, endpoint, primaryKey } = config;
+
+ // --- READ ALL ---
+ const useList = (params?: any) =>
+ useQuery({
+ queryKey: [name, "list", params],
+ queryFn: async () => {
+ const res = await api.get(endpoint, { params });
+ return res.data;
+ }
+ });
+
+ // --- READ ONE ---
+ const useOne = (id: string | null) =>
+ useQuery({
+ queryKey: [name, "detail", id],
+ queryFn: async () => {
+ if (!id) return null;
+ const res = await api.get(`${endpoint}/${id}`);
+ return res.data;
+ },
+ enabled: !!id,
+ });
+
+ // --- CREATE ---
+ const useCreate = () =>
+ useMutation({
+ mutationFn: async (data: Partial) => {
+ const res = await api.post(endpoint, data);
+ return res.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: [name, "list"] });
+ },
+ });
+
+ // --- UPDATE ---
+ const useUpdate = () =>
+ useMutation({
+ mutationFn: async ({ id, data }: { id: string; data: Partial }) => {
+ const res = await api.put(`${endpoint}/${id}`, data);
+ return res.data;
+ },
+ onSuccess: (updatedItem) => {
+ // @ts-ignore
+ const id = updatedItem[primaryKey];
+ queryClient.invalidateQueries({ queryKey: [name, "list"] });
+ queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
+ },
+ });
+
+ // --- DELETE ---
+ const useDelete = () =>
+ useMutation({
+ mutationFn: async (id: string) => {
+ await api.delete(`${endpoint}/${id}`);
+ return id;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: [name, "list"] });
+ },
+ });
+
+ return {
+ useList,
+ useOne,
+ useCreate,
+ useUpdate,
+ useDelete,
+ };
+}
diff --git a/src_generic/main.tsx b/src_generic/main.tsx
new file mode 100644
index 0000000..080ad13
--- /dev/null
+++ b/src_generic/main.tsx
@@ -0,0 +1,12 @@
+import * as React from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+
+const rootElement = document.getElementById('root');
+const root = createRoot(rootElement!);
+
+root.render(
+
+
+
+);
diff --git a/src_generic/types/config.ts b/src_generic/types/config.ts
new file mode 100644
index 0000000..e66563d
--- /dev/null
+++ b/src_generic/types/config.ts
@@ -0,0 +1,33 @@
+export type FieldType =
+ | 'string'
+ | 'number'
+ | 'boolean'
+ | 'date'
+ | 'markdown'
+ | 'enum'
+ | 'object'
+ | 'array';
+
+export interface ResourceField {
+ type: FieldType;
+ label: string;
+ required?: boolean;
+ options?: string[]; // for enum
+ schema?: Record; // for object or array items
+ readOnly?: boolean;
+}
+
+export interface ResourceConfig {
+ name: string;
+ label: string;
+ pluralLabel: string;
+ endpoint: string;
+ primaryKey: string;
+ fields: Record;
+}
+
+export interface AppConfig {
+ baseUrl: string;
+ authBaseUrl: string;
+ resources: ResourceConfig[];
+}