mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
public commit
This commit is contained in:
83
app/src/ui/client/BkndProvider.tsx
Normal file
83
app/src/ui/client/BkndProvider.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
||||
import { useClient } from "./ClientProvider";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
|
||||
type BkndContext = {
|
||||
version: number;
|
||||
schema: ModuleSchemas;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
app: AppReduced;
|
||||
};
|
||||
|
||||
const BkndContext = createContext<BkndContext>(undefined!);
|
||||
export type { TSchemaActions };
|
||||
|
||||
export function BkndProvider({
|
||||
includeSecrets = false,
|
||||
children
|
||||
}: { includeSecrets?: boolean; children: any }) {
|
||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||
const [schema, setSchema] = useState<BkndContext>();
|
||||
const client = useClient();
|
||||
|
||||
async function fetchSchema(_includeSecrets: boolean = false) {
|
||||
if (withSecrets) return;
|
||||
const { body } = await client.api.system.readSchema({
|
||||
config: true,
|
||||
secrets: _includeSecrets
|
||||
});
|
||||
console.log("--schema fetched", body);
|
||||
setSchema(body as any);
|
||||
setWithSecrets(_includeSecrets);
|
||||
}
|
||||
|
||||
async function requireSecrets() {
|
||||
if (withSecrets) return;
|
||||
await fetchSchema(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (schema?.schema) return;
|
||||
fetchSchema(includeSecrets);
|
||||
}, []);
|
||||
|
||||
if (!schema?.schema) return null;
|
||||
const app = new AppReduced(schema.config as any);
|
||||
|
||||
const actions = getSchemaActions({ client, setSchema });
|
||||
|
||||
return (
|
||||
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app }}>
|
||||
{children}
|
||||
</BkndContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
|
||||
const ctx = useContext(BkndContext);
|
||||
if (withSecrets) ctx.requireSecrets();
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/*
|
||||
type UseSchemaForType<Key extends keyof ModuleSchemas> = {
|
||||
version: number;
|
||||
schema: ModuleSchemas[Key];
|
||||
config: ModuleConfigs[Key];
|
||||
};
|
||||
|
||||
export function useSchemaFor<Key extends keyof ModuleConfigs>(module: Key): UseSchemaForType<Key> {
|
||||
//const app = useApp();
|
||||
const { version, schema, config } = useSchema();
|
||||
return {
|
||||
version,
|
||||
schema: schema[module],
|
||||
config: config[module]
|
||||
};
|
||||
}*/
|
||||
82
app/src/ui/client/ClientProvider.tsx
Normal file
82
app/src/ui/client/ClientProvider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { AppQueryClient } from "./utils/AppQueryClient";
|
||||
|
||||
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
|
||||
baseUrl: undefined
|
||||
} as any);
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?: string }) => {
|
||||
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||
|
||||
try {
|
||||
const _ctx_baseUrl = useBaseUrl();
|
||||
if (_ctx_baseUrl) {
|
||||
console.warn("wrapped many times");
|
||||
setActualBaseUrl(_ctx_baseUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Only set base URL if running on the client side
|
||||
if (typeof window !== "undefined") {
|
||||
setActualBaseUrl(baseUrl || window.location.origin);
|
||||
}
|
||||
}, [baseUrl]);
|
||||
|
||||
if (!actualBaseUrl) {
|
||||
// Optionally, return a fallback during SSR rendering
|
||||
return null; // or a loader/spinner if desired
|
||||
}
|
||||
|
||||
console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl });
|
||||
const client = createClient(actualBaseUrl);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientContext.Provider value={{ baseUrl: actualBaseUrl, client }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export function createClient(baseUrl: string = window.location.origin) {
|
||||
return new AppQueryClient(baseUrl);
|
||||
}
|
||||
|
||||
export function createOrUseClient(baseUrl: string = window.location.origin) {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
console.warn("createOrUseClient returned a new client");
|
||||
return createClient(baseUrl);
|
||||
}
|
||||
|
||||
return context.client;
|
||||
}
|
||||
|
||||
export const useClient = () => {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
throw new Error("useClient must be used within a ClientProvider");
|
||||
}
|
||||
|
||||
console.log("useClient", context.baseUrl);
|
||||
return context.client;
|
||||
};
|
||||
|
||||
export const useBaseUrl = () => {
|
||||
const context = useContext(ClientContext);
|
||||
return context.baseUrl;
|
||||
};
|
||||
4
app/src/ui/client/index.ts
Normal file
4
app/src/ui/client/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ClientProvider, useClient, useBaseUrl } from "./ClientProvider";
|
||||
export { BkndProvider, useBknd } from "./BkndProvider";
|
||||
|
||||
export { useAuth } from "./schema/auth/use-auth";
|
||||
190
app/src/ui/client/schema/actions.ts
Normal file
190
app/src/ui/client/schema/actions.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { set } from "lodash-es";
|
||||
import type { ModuleConfigs } from "../../../modules";
|
||||
import type { AppQueryClient } from "../utils/AppQueryClient";
|
||||
|
||||
export type SchemaActionsProps = {
|
||||
client: AppQueryClient;
|
||||
setSchema: React.Dispatch<React.SetStateAction<any>>;
|
||||
};
|
||||
|
||||
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
|
||||
|
||||
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
|
||||
const baseUrl = client.baseUrl;
|
||||
const token = client.auth().state()?.token;
|
||||
return {
|
||||
set: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
value: ModuleConfigs[Module],
|
||||
force?: boolean
|
||||
) => {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/system/config/set/${module}?force=${force ? 1 : 0}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config set", module, data);
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
patch: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
): Promise<boolean> => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/patch/${module}/${path}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config patch", module, path, data);
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
overwrite: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/overwrite/${module}/${path}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config overwrite", module, path, data);
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
add: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/add/${module}/${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config add", module, data);
|
||||
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
remove: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/remove/${module}/${path}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as any;
|
||||
console.log("update config remove", module, data);
|
||||
|
||||
if (data.success) {
|
||||
setSchema((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[module]: data.config
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
107
app/src/ui/client/schema/auth/use-auth.ts
Normal file
107
app/src/ui/client/schema/auth/use-auth.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Api } from "Api";
|
||||
import type { AuthResponse } from "auth";
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
import type { ApiResponse } from "modules/ModuleApi";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
createClient,
|
||||
createOrUseClient,
|
||||
queryClient,
|
||||
useBaseUrl,
|
||||
useClient
|
||||
} from "../../ClientProvider";
|
||||
|
||||
type LoginData = {
|
||||
email: string;
|
||||
password: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type UseAuth = {
|
||||
data: (AuthResponse & { verified: boolean }) | undefined;
|
||||
user: AuthResponse["user"] | undefined;
|
||||
token: AuthResponse["token"] | undefined;
|
||||
verified: boolean;
|
||||
login: (data: LoginData) => Promise<ApiResponse<AuthResponse>>;
|
||||
register: (data: LoginData) => Promise<ApiResponse<AuthResponse>>;
|
||||
logout: () => void;
|
||||
verify: () => void;
|
||||
setToken: (token: string) => void;
|
||||
};
|
||||
|
||||
// @todo: needs to use a specific auth endpoint to get strategy information
|
||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
const ctxBaseUrl = useBaseUrl();
|
||||
//const client = useClient();
|
||||
const client = createOrUseClient(options?.baseUrl ? options?.baseUrl : ctxBaseUrl);
|
||||
const authState = client.auth().state();
|
||||
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
|
||||
const verified = authState?.verified ?? false;
|
||||
|
||||
async function login(input: LoginData) {
|
||||
const res = await client.auth().login(input);
|
||||
if (res.res.ok && res.data && "user" in res.data) {
|
||||
setAuthData(res.data);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function register(input: LoginData) {
|
||||
const res = await client.auth().register(input);
|
||||
if (res.res.ok && res.data && "user" in res.data) {
|
||||
setAuthData(res.data);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
setAuthData(client.auth().setToken(token) as any);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await client.auth().logout();
|
||||
setAuthData(undefined);
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await client.auth().verify();
|
||||
setAuthData(client.auth().state());
|
||||
}
|
||||
|
||||
return {
|
||||
data: authData,
|
||||
user: authData?.user,
|
||||
token: authData?.token,
|
||||
verified,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
setToken,
|
||||
verify
|
||||
};
|
||||
};
|
||||
|
||||
export const useAuthStrategies = (options?: { baseUrl?: string }): {
|
||||
strategies: AppAuthSchema["strategies"];
|
||||
loading: boolean;
|
||||
} => {
|
||||
const [strategies, setStrategies] = useState<AppAuthSchema["strategies"]>();
|
||||
const ctxBaseUrl = useBaseUrl();
|
||||
const api = new Api({
|
||||
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl,
|
||||
tokenStorage: "localStorage"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await api.auth.strategies();
|
||||
console.log("res", res);
|
||||
if (res.res.ok) {
|
||||
setStrategies(res.body.strategies);
|
||||
}
|
||||
})();
|
||||
}, [options?.baseUrl]);
|
||||
|
||||
return { strategies, loading: !strategies };
|
||||
};
|
||||
33
app/src/ui/client/schema/auth/use-bknd-auth.ts
Normal file
33
app/src/ui/client/schema/auth/use-bknd-auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useBknd } from "ui/client";
|
||||
|
||||
export function useBkndAuth() {
|
||||
//const client = useClient();
|
||||
const { config, app, schema, actions: bkndActions } = useBknd();
|
||||
|
||||
const actions = {
|
||||
roles: {
|
||||
add: async (name: string, data: any = {}) => {
|
||||
console.log("add role", name, data);
|
||||
return await bkndActions.add("auth", `roles.${name}`, data);
|
||||
},
|
||||
patch: async (name: string, data: any) => {
|
||||
console.log("patch role", name, data);
|
||||
return await bkndActions.patch("auth", `roles.${name}`, data);
|
||||
},
|
||||
delete: async (name: string) => {
|
||||
console.log("delete role", name);
|
||||
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
|
||||
return await bkndActions.remove("auth", `roles.${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const $auth = {};
|
||||
|
||||
return {
|
||||
$auth,
|
||||
config: config.auth,
|
||||
schema: schema.auth,
|
||||
actions
|
||||
};
|
||||
}
|
||||
115
app/src/ui/client/schema/data/use-bknd-data.ts
Normal file
115
app/src/ui/client/schema/data/use-bknd-data.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
|
||||
import type { Entity } from "data";
|
||||
import { AppData } from "data/AppData";
|
||||
import {
|
||||
type TAppDataEntity,
|
||||
type TAppDataEntityFields,
|
||||
type TAppDataField,
|
||||
type TAppDataRelation,
|
||||
entitiesSchema,
|
||||
entityFields,
|
||||
fieldsSchema,
|
||||
relationsSchema
|
||||
} from "data/data-schema";
|
||||
import { useBknd } from "ui/client";
|
||||
import type { TSchemaActions } from "ui/client/schema/actions";
|
||||
|
||||
export function useBkndData() {
|
||||
const { config, app, schema, actions: bkndActions } = useBknd();
|
||||
|
||||
// @todo: potentially store in ref, so it doesn't get recomputed? or use memo?
|
||||
const entities = transformObject(config.data.entities ?? {}, (entity, name) => {
|
||||
return AppData.constructEntity(name, entity);
|
||||
});
|
||||
|
||||
const actions = {
|
||||
entity: {
|
||||
add: async (name: string, data: TAppDataEntity) => {
|
||||
console.log("create entity", { data });
|
||||
const validated = parse(entitiesSchema, data, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
console.log("validated", validated);
|
||||
// @todo: check for existing?
|
||||
return await bkndActions.add("data", `entities.${name}`, validated);
|
||||
},
|
||||
patch: (entityName: string) => {
|
||||
const entity = entities[entityName];
|
||||
if (!entity) {
|
||||
throw new Error(`Entity "${entityName}" not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => {
|
||||
console.log("patch config", entityName, partial);
|
||||
return await bkndActions.patch("data", `entities.${entityName}.config`, partial);
|
||||
},
|
||||
fields: entityFieldActions(bkndActions, entityName)
|
||||
};
|
||||
}
|
||||
},
|
||||
relations: {
|
||||
add: async (relation: TAppDataRelation) => {
|
||||
console.log("create relation", { relation });
|
||||
const name = crypto.randomUUID();
|
||||
const validated = parse(Type.Union(relationsSchema), relation, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
console.log("validated", validated);
|
||||
return await bkndActions.add("data", `relations.${name}`, validated);
|
||||
}
|
||||
}
|
||||
};
|
||||
const $data = {
|
||||
entity: (name: string) => entities[name]
|
||||
};
|
||||
|
||||
return {
|
||||
$data,
|
||||
entities,
|
||||
relations: app.relations,
|
||||
config: config.data,
|
||||
schema: schema.data,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
|
||||
return {
|
||||
add: async (name: string, field: TAppDataField) => {
|
||||
console.log("create field", { name, field });
|
||||
const validated = parse(fieldsSchema, field, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
console.log("validated", validated);
|
||||
return await bkndActions.add("data", `entities.${entityName}.fields.${name}`, validated);
|
||||
},
|
||||
patch: () => null,
|
||||
set: async (fields: TAppDataEntityFields) => {
|
||||
console.log("set fields", entityName, fields);
|
||||
try {
|
||||
const validated = parse(entityFields, fields, {
|
||||
skipMark: true,
|
||||
forceParse: true
|
||||
});
|
||||
const res = await bkndActions.overwrite(
|
||||
"data",
|
||||
`entities.${entityName}.fields`,
|
||||
validated
|
||||
);
|
||||
console.log("res", res);
|
||||
//bkndActions.set("data", "entities", fields);
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
if (e instanceof TypeInvalidError) {
|
||||
alert("Error updating fields: " + e.firstToString());
|
||||
} else {
|
||||
alert("An error occured, check console. There will be nice error handling soon.");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
23
app/src/ui/client/schema/flows/use-flows.ts
Normal file
23
app/src/ui/client/schema/flows/use-flows.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type Static, parse } from "core/utils";
|
||||
import { type TAppFlowSchema, flowSchema } from "flows/flows-schema";
|
||||
import { useBknd } from "../../BkndProvider";
|
||||
import { useClient } from "../../ClientProvider";
|
||||
|
||||
export function useFlows() {
|
||||
const client = useClient();
|
||||
const { config, app, actions: bkndActions } = useBknd();
|
||||
|
||||
const actions = {
|
||||
flow: {
|
||||
create: async (name: string, data: TAppFlowSchema) => {
|
||||
console.log("would create", name, data);
|
||||
const parsed = parse(flowSchema, data, { skipMark: true, forceParse: true });
|
||||
console.log("parsed", parsed);
|
||||
const res = await bkndActions.add("flows", `flows.${name}`, parsed);
|
||||
console.log("res", res);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { flows: app.flows, config: config.flows, actions };
|
||||
}
|
||||
40
app/src/ui/client/schema/system/use-bknd-system.ts
Normal file
40
app/src/ui/client/schema/system/use-bknd-system.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useBknd } from "ui/client";
|
||||
|
||||
export function useBkndSystem() {
|
||||
const { config, schema, actions: bkndActions } = useBknd();
|
||||
const theme = config.server.admin.color_scheme ?? "light";
|
||||
|
||||
const actions = {
|
||||
theme: {
|
||||
set: async (scheme: "light" | "dark") => {
|
||||
return await bkndActions.patch("server", "admin", {
|
||||
color_scheme: scheme
|
||||
});
|
||||
},
|
||||
toggle: async () => {
|
||||
return await bkndActions.patch("server", "admin", {
|
||||
color_scheme: theme === "light" ? "dark" : "light"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const $system = {};
|
||||
|
||||
return {
|
||||
$system,
|
||||
config: config.server,
|
||||
schema: schema.server,
|
||||
theme,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
export function useBkndSystemTheme() {
|
||||
const $sys = useBkndSystem();
|
||||
|
||||
return {
|
||||
theme: $sys.theme,
|
||||
set: $sys.actions.theme.set,
|
||||
toggle: () => $sys.actions.theme.toggle()
|
||||
};
|
||||
}
|
||||
8
app/src/ui/client/use-theme.ts
Normal file
8
app/src/ui/client/use-theme.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useBknd } from "ui";
|
||||
|
||||
export function useTheme(): { theme: "light" | "dark" } {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme as any;
|
||||
|
||||
return { theme };
|
||||
}
|
||||
211
app/src/ui/client/utils/AppQueryClient.ts
Normal file
211
app/src/ui/client/utils/AppQueryClient.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
type QueryObserverOptions,
|
||||
type UseQueryResult,
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery
|
||||
} from "@tanstack/react-query";
|
||||
import type { AuthResponse } from "auth";
|
||||
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
|
||||
import { Api } from "../../../Api";
|
||||
import type { ApiResponse } from "../../../modules/ModuleApi";
|
||||
import { queryClient } from "../ClientProvider";
|
||||
|
||||
export class AppQueryClient {
|
||||
api: Api;
|
||||
constructor(public baseUrl: string) {
|
||||
this.api = new Api({
|
||||
host: baseUrl,
|
||||
tokenStorage: "localStorage"
|
||||
});
|
||||
}
|
||||
|
||||
queryOptions(options?: Partial<QueryObserverOptions>): Partial<QueryObserverOptions> {
|
||||
return {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
placeholderData: keepPreviousData,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
auth = () => {
|
||||
return {
|
||||
state: (): (AuthResponse & { verified: boolean }) | undefined => {
|
||||
return this.api.getAuthState() as any;
|
||||
},
|
||||
login: async (data: { email: string; password: string }): Promise<
|
||||
ApiResponse<AuthResponse>
|
||||
> => {
|
||||
return await this.api.auth.loginWithPassword(data);
|
||||
},
|
||||
register: async (data: any): Promise<ApiResponse<AuthResponse>> => {
|
||||
return await this.api.auth.registerWithPassword(data);
|
||||
},
|
||||
logout: async () => {
|
||||
this.api.updateToken(undefined);
|
||||
return true;
|
||||
},
|
||||
setToken: (token) => {
|
||||
this.api.updateToken(token);
|
||||
return this.api.getAuthState();
|
||||
},
|
||||
verify: async () => {
|
||||
console.log("verifiying");
|
||||
const res = await this.api.auth.me();
|
||||
console.log("verifying result", res);
|
||||
if (!res.res.ok) {
|
||||
this.api.markAuthVerified(false);
|
||||
this.api.updateToken(undefined);
|
||||
} else {
|
||||
this.api.markAuthVerified(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
media = (options?: Partial<QueryObserverOptions>) => {
|
||||
const queryOptions = this.queryOptions(options);
|
||||
return {
|
||||
api: () => {
|
||||
return this.api.media;
|
||||
},
|
||||
list: (query: Partial<RepoQuery> = { limit: 10 }): UseQueryResult<ApiResponse> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: ["data", "entity", "media", { query }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readMany("media", query);
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteFile: async (filename: string | { path: string }) => {
|
||||
const res = await this.api.media.deleteFile(
|
||||
typeof filename === "string" ? filename : filename.path
|
||||
);
|
||||
|
||||
if (res.res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", "media"] });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
query = (options?: Partial<QueryObserverOptions>) => {
|
||||
const queryOptions = this.queryOptions(options);
|
||||
return {
|
||||
data: {
|
||||
entity: (name: string) => {
|
||||
return {
|
||||
readOne: (
|
||||
id: number,
|
||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
||||
): any => {
|
||||
return useQuery({
|
||||
...queryOptions,
|
||||
queryKey: ["data", "entity", name, id, { query }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readOne(name, id, query);
|
||||
}
|
||||
});
|
||||
},
|
||||
readMany: (
|
||||
query: Partial<RepoQuery> = { limit: 10, offset: 0 }
|
||||
): UseQueryResult<ApiResponse> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: ["data", "entity", name, { query }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readMany(name, query);
|
||||
}
|
||||
});
|
||||
},
|
||||
readManyByReference: (
|
||||
id: number,
|
||||
reference: string,
|
||||
referenced_entity?: string, // required for query invalidation
|
||||
query: Partial<RepoQuery> = { limit: 10, offset: 0 }
|
||||
): UseQueryResult<Pick<RepositoryResponse, "meta" | "data">> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: [
|
||||
"data",
|
||||
"entity",
|
||||
referenced_entity ?? reference,
|
||||
{ name, id, reference, query }
|
||||
],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.readManyByReference(
|
||||
name,
|
||||
id,
|
||||
reference,
|
||||
query
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
count: (
|
||||
where: RepoQuery["where"] = {}
|
||||
): UseQueryResult<ApiResponse<{ entity: string; count: number }>> => {
|
||||
return useQuery({
|
||||
...(queryOptions as any), // @todo: fix typing
|
||||
queryKey: ["data", "entity", name, "fn", "count", { where }],
|
||||
queryFn: async () => {
|
||||
return await this.api.data.count(name, where);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// @todo: centralize, improve
|
||||
__invalidate = (...args: any[]) => {
|
||||
console.log("___invalidate", ["data", "entity", ...args]);
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", ...args] });
|
||||
};
|
||||
|
||||
// @todo: must return response... why?
|
||||
mutation = {
|
||||
data: {
|
||||
entity: (name: string) => {
|
||||
return {
|
||||
update: (id: number): any => {
|
||||
return useMutation({
|
||||
mutationFn: async (input: EntityData) => {
|
||||
return await this.api.data.updateOne(name, id, input);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
|
||||
}
|
||||
});
|
||||
},
|
||||
create: (): any => {
|
||||
return useMutation({
|
||||
mutationFn: async (input: EntityData) => {
|
||||
return await this.api.data.createOne(name, input);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
|
||||
}
|
||||
});
|
||||
},
|
||||
delete: (id: number): any => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
return await this.api.data.deleteOne(name, id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
83
app/src/ui/client/utils/AppReduced.ts
Normal file
83
app/src/ui/client/utils/AppReduced.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { App } from "App";
|
||||
import type { Entity, EntityRelation } from "data";
|
||||
import { AppData } from "data/AppData";
|
||||
import { RelationAccessor } from "data/relations/RelationAccessor";
|
||||
import { Flow, TaskMap } from "flows";
|
||||
|
||||
export type AppType = ReturnType<App["toJSON"]>;
|
||||
|
||||
/**
|
||||
* Reduced version of the App class for frontend use
|
||||
*/
|
||||
export class AppReduced {
|
||||
// @todo: change to record
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _flows: Flow[] = [];
|
||||
|
||||
constructor(protected appJson: AppType) {
|
||||
console.log("received appjson", appJson);
|
||||
|
||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||
return AppData.constructEntity(name, entity);
|
||||
});
|
||||
|
||||
this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => {
|
||||
return AppData.constructRelation(relation, this.entity.bind(this));
|
||||
});
|
||||
|
||||
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
|
||||
// @ts-ignore
|
||||
// @todo: fix constructing flow
|
||||
const flow = Flow.fromObject(name, obj, TaskMap);
|
||||
|
||||
this._flows.push(flow);
|
||||
}
|
||||
}
|
||||
|
||||
get entities(): Entity[] {
|
||||
return this._entities;
|
||||
}
|
||||
|
||||
// @todo: change to record
|
||||
entity(_entity: Entity | string): Entity {
|
||||
const name = typeof _entity === "string" ? _entity : _entity.name;
|
||||
const entity = this._entities.find((entity) => entity.name === name);
|
||||
if (!entity) {
|
||||
throw new Error(`Entity "${name}" not found`);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
get relations(): RelationAccessor {
|
||||
return new RelationAccessor(this._relations);
|
||||
}
|
||||
|
||||
get flows(): Flow[] {
|
||||
return this._flows;
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.appJson;
|
||||
}
|
||||
|
||||
getAdminConfig() {
|
||||
return this.appJson.server.admin;
|
||||
}
|
||||
|
||||
getSettingsPath(path: string[] = []): string {
|
||||
const { basepath } = this.getAdminConfig();
|
||||
const base = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
return [base, ...path].join("/");
|
||||
}
|
||||
|
||||
getAbsolutePath(path?: string): string {
|
||||
const { basepath } = this.getAdminConfig();
|
||||
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
getAuthConfig() {
|
||||
return this.appJson.auth;
|
||||
}
|
||||
}
|
||||
28
app/src/ui/client/utils/theme-switcher.ts
Normal file
28
app/src/ui/client/utils/theme-switcher.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from "react";
|
||||
export type AppTheme = "light" | "dark" | string;
|
||||
|
||||
export function useSetTheme(initialTheme: AppTheme = "light") {
|
||||
const [theme, _setTheme] = useState(initialTheme);
|
||||
|
||||
const $html = document.querySelector("#bknd-admin")!;
|
||||
function setTheme(newTheme: AppTheme) {
|
||||
$html?.classList.remove("dark", "light");
|
||||
$html?.classList.add(newTheme);
|
||||
_setTheme(newTheme);
|
||||
|
||||
// @todo: just a quick switcher config update test
|
||||
fetch("/api/system/config/patch/server/admin", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ color_scheme: newTheme })
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("theme updated", data);
|
||||
});
|
||||
}
|
||||
|
||||
return { theme, setTheme };
|
||||
}
|
||||
Reference in New Issue
Block a user