Merge pull request #60 from bknd-io/release/0.7

Release 0.7
This commit is contained in:
dswbx
2025-02-08 16:38:07 +01:00
committed by GitHub
100 changed files with 3444 additions and 630 deletions

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import { sign } from "hono/jwt";
import { Api } from "../../src/Api";
describe("Api", async () => {
it("should construct without options", () => {
const api = new Api();
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should ignore force verify if no claims given", () => {
const api = new Api({ verified: true });
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should construct from request (token)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Authorization: `Bearer ${token}`
}
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://example.com");
});
it("should construct from request (cookie)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Cookie: `auth=${token}`
}
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
console.log(params);
expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com");
});
it("should construct from token", async () => {
const token = await sign({ foo: "bar" }, "1234");
const api = new Api({ token });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://localhost");
});
it("should prefer host when request is given", async () => {
const request = new Request("http://example.com/test");
const api = new Api({ request, host: "http://another.com" });
const params = api.getParams();
expect(params.token).toBeUndefined();
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://another.com");
});
});

View File

@@ -27,10 +27,7 @@ describe("ModuleApi", () => {
it("fetches endpoint", async () => { it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const res = await api.get("/endpoint"); const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true); expect(res.res.ok).toEqual(true);
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
it("has accessible request", async () => { it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const promise = api.get("/endpoint"); const promise = api.get("/endpoint");
expect(promise.request).toBeDefined(); expect(promise.request).toBeDefined();

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Perf } from "../../src/core/utils"; import { Perf } from "../../src/core/utils";
import * as reqres from "../../src/core/utils/reqres"; import * as utils from "../../src/core/utils";
import * as strings from "../../src/core/utils/strings";
async function wait(ms: number) { async function wait(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
describe("[core] strings", async () => { describe("[core] strings", async () => {
test("objectToKeyValueArray", async () => { test("objectToKeyValueArray", async () => {
const obj = { a: 1, b: 2, c: 3 }; const obj = { a: 1, b: 2, c: 3 };
const result = strings.objectToKeyValueArray(obj); const result = utils.objectToKeyValueArray(obj);
expect(result).toEqual([ expect(result).toEqual([
{ key: "a", value: 1 }, { key: "a", value: 1 },
{ key: "b", value: 2 }, { key: "b", value: 2 },
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
}); });
test("snakeToPascalWithSpaces", async () => { test("snakeToPascalWithSpaces", async () => {
const result = strings.snakeToPascalWithSpaces("snake_to_pascal"); const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
expect(result).toBe("Snake To Pascal"); expect(result).toBe("Snake To Pascal");
}); });
test("randomString", async () => { test("randomString", async () => {
const result = strings.randomString(10); const result = utils.randomString(10);
expect(result).toHaveLength(10); expect(result).toHaveLength(10);
}); });
test("pascalToKebab", async () => { test("pascalToKebab", async () => {
const result = strings.pascalToKebab("PascalCase"); const result = utils.pascalToKebab("PascalCase");
expect(result).toBe("pascal-case"); expect(result).toBe("pascal-case");
}); });
test("replaceSimplePlaceholders", async () => { test("replaceSimplePlaceholders", async () => {
const str = "Hello, {$name}!"; const str = "Hello, {$name}!";
const vars = { name: "John" }; const vars = { name: "John" };
const result = strings.replaceSimplePlaceholders(str, vars); const result = utils.replaceSimplePlaceholders(str, vars);
expect(result).toBe("Hello, John!"); expect(result).toBe("Hello, John!");
}); });
}); });
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
const headers = new Headers(); const headers = new Headers();
headers.append("Content-Type", "application/json"); headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer 123"); headers.append("Authorization", "Bearer 123");
const obj = reqres.headersToObject(headers); const obj = utils.headersToObject(headers);
expect(obj).toEqual({ expect(obj).toEqual({
"content-type": "application/json", "content-type": "application/json",
authorization: "Bearer 123" authorization: "Bearer 123"
@@ -59,21 +58,21 @@ describe("Core Utils", async () => {
test("replaceUrlParam", () => { test("replaceUrlParam", () => {
const url = "/api/:id/:name"; const url = "/api/:id/:name";
const params = { id: "123", name: "test" }; const params = { id: "123", name: "test" };
const result = reqres.replaceUrlParam(url, params); const result = utils.replaceUrlParam(url, params);
expect(result).toBe("/api/123/test"); expect(result).toBe("/api/123/test");
}); });
test("encode", () => { test("encode", () => {
const obj = { id: "123", name: "test" }; const obj = { id: "123", name: "test" };
const result = reqres.encodeSearch(obj); const result = utils.encodeSearch(obj);
expect(result).toBe("id=123&name=test"); expect(result).toBe("id=123&name=test");
const obj2 = { id: "123", name: ["test1", "test2"] }; const obj2 = { id: "123", name: ["test1", "test2"] };
const result2 = reqres.encodeSearch(obj2); const result2 = utils.encodeSearch(obj2);
expect(result2).toBe("id=123&name=test1&name=test2"); expect(result2).toBe("id=123&name=test1&name=test2");
const obj3 = { id: "123", name: { test: "test" } }; const obj3 = { id: "123", name: { test: "test" } };
const result3 = reqres.encodeSearch(obj3, { encode: true }); const result3 = utils.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
}); });
}); });
@@ -108,4 +107,91 @@ describe("Core Utils", async () => {
expect(count).toBe(2); expect(count).toBe(2);
}); });
}); });
describe("objects", () => {
test("omitKeys", () => {
const objects = [
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}]
] as [object, string[], object][];
for (const [obj, keys, expected] of objects) {
const result = utils.omitKeys(obj, keys as any);
expect(result).toEqual(expected);
}
});
test("isEqual", () => {
const objects = [
[1, 1, true],
[1, "1", false],
[1, 2, false],
["1", "1", true],
["1", "2", false],
[true, true, true],
[true, false, false],
[false, false, true],
[1, NaN, false],
[NaN, NaN, true],
[null, null, true],
[null, undefined, false],
[undefined, undefined, true],
[new Map([["a", 1]]), new Map([["a", 1]]), true],
[new Map([["a", 1]]), new Map([["a", 2]]), false],
[new Map([["a", 1]]), new Map([["b", 1]]), false],
[
new Map([["a", 1]]),
new Map([
["a", 1],
["b", 2]
]),
false
],
[{ a: 1 }, { a: 1 }, true],
[{ a: 1 }, { a: 2 }, false],
[{ a: 1 }, { b: 1 }, false],
[{ a: "1" }, { a: "1" }, true],
[{ a: "1" }, { a: "2" }, false],
[{ a: "1" }, { b: "1" }, false],
[{ a: 1 }, { a: 1, b: 2 }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
[[1, 2, 3], [1, 2, 3], true],
[[1, 2, 3], [1, 2, 4], false],
[[1, 2, 3], [1, 2, 3, 4], false],
[[{ a: 1 }], [{ a: 1 }], true],
[[{ a: 1 }], [{ a: 2 }], false],
[[{ a: 1 }], [{ b: 1 }], false]
] as [any, any, boolean][];
for (const [a, b, expected] of objects) {
const result = utils.isEqual(a, b);
expect(result).toEqual(expected);
}
});
test("getPath", () => {
const tests = [
[{ a: 1, b: 2, c: 3 }, "a", 1],
[{ a: 1, b: 2, c: 3 }, "b", 2],
[{ a: { b: 1 } }, "a.b", 1],
[{ a: { b: 1 } }, "a.b.c", null, null],
[{ a: { b: 1 } }, "a.b.c", 1, 1],
[[[1]], "0.0", 1]
] as [object, string, any, any][];
for (const [obj, path, expected, defaultValue] of tests) {
const result = utils.getPath(obj, path, defaultValue);
expect(result).toEqual(expected);
}
});
});
}); });

View File

@@ -123,6 +123,23 @@ describe("data-query-impl", () => {
} }
} }
); );
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
}); });
}); });

View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import { Draft2019 } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts";
import * as utils from "../../src/ui/components/form/json-schema-form/utils";
import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils";
describe("json form", () => {
test("coerse", () => {
const examples = [
["test", { type: "string" }, "test"],
["1", { type: "integer" }, 1],
["1", { type: "number" }, 1],
["true", { type: "boolean" }, true],
["false", { type: "boolean" }, false],
["1", { type: "boolean" }, true],
["0", { type: "boolean" }, false],
["on", { type: "boolean" }, true],
["off", { type: "boolean" }, false],
["null", { type: "null" }, null]
] satisfies [string, Exclude<JSONSchema, boolean>, any][];
for (const [input, schema, output] of examples) {
expect(utils.coerce(input, schema)).toBe(output);
}
});
test("isType", () => {
const examples = [
["string", "string", true],
["integer", "number", false],
["number", "number", true],
["boolean", "boolean", true],
["null", "null", true],
["object", "object", true],
["array", "array", true],
["object", "array", false],
[["string", "number"], "number", true],
["number", ["string", "number"], true]
] satisfies [IsTypeType, IsTypeType, boolean][];
for (const [type, schemaType, output] of examples) {
expect(utils.isType(type, schemaType)).toBe(output);
}
});
test("getParentPointer", () => {
const examples = [
["#/nested/property/0/name", "#/nested/property/0"],
["#/nested/property/0", "#/nested/property"],
["#/nested/property", "#/nested"],
["#/nested", "#"]
];
for (const [input, output] of examples) {
expect(utils.getParentPointer(input)).toBe(output);
}
});
test("isRequired", () => {
const examples = [
[
"#/description",
{ type: "object", properties: { description: { type: "string" } } },
false
],
[
"#/description",
{
type: "object",
required: ["description"],
properties: { description: { type: "string" } }
},
true
],
[
"#/nested/property",
{
type: "object",
properties: {
nested: {
type: "object",
properties: { property: { type: "string" } }
}
}
},
false
],
[
"#/nested/property",
{
type: "object",
properties: {
nested: {
type: "object",
required: ["property"],
properties: { property: { type: "string" } }
}
}
},
true
]
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
for (const [pointer, schema, output] of examples) {
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
}
});
test("prefixPath", () => {
const examples = [
["normal", "", "normal"],
["", "prefix", "prefix"],
["tags", "0", "0.tags"],
["tags", 0, "0.tags"],
["nested.property", "prefix", "prefix.nested.property"],
["nested.property", "", "nested.property"]
] satisfies [string, any, string][];
for (const [path, prefix, output] of examples) {
expect(utils.prefixPath(path, prefix)).toBe(output);
}
});
test("suffixPath", () => {
const examples = [
["normal", "", "normal"],
["", "suffix", "suffix"],
["tags", "0", "tags.0"],
["tags", 0, "tags.0"],
["nested.property", "suffix", "nested.property.suffix"],
["nested.property", "", "nested.property"]
] satisfies [string, any, string][];
for (const [path, suffix, output] of examples) {
expect(utils.suffixPath(path, suffix)).toBe(output);
}
});
});

View File

@@ -173,13 +173,20 @@ function baseConfig(adapter: string): tsup.Options {
], ],
metafile: true, metafile: true,
splitting: false, splitting: false,
treeshake: true,
onSuccess: async () => { onSuccess: async () => {
delayTypes(); delayTypes();
} }
}; };
} }
// base adapter handles
await tsup.build({
...baseConfig(""),
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter"
});
// specific adatpers
await tsup.build(baseConfig("remix")); await tsup.build(baseConfig("remix"));
await tsup.build(baseConfig("bun")); await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro")); await tsup.build(baseConfig("astro"));

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.6.2", "version": "0.7.0-rc.11",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -33,26 +33,30 @@
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^2.0.1", "@cfworker/json-schema": "^2.0.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@hello-pangea/dnd": "^17.0.0",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@mantine/core": "^7.13.4",
"@sinclair/typebox": "^0.32.34", "@sinclair/typebox": "^0.32.34",
"@tanstack/react-form": "0.19.2", "@tanstack/react-form": "0.19.2",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18", "aws4fetch": "^1.0.18",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^4.4.0",
"hono": "^4.6.12", "hono": "^4.6.12",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"liquidjs": "^10.15.0", "liquidjs": "^10.15.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"swr": "^2.2.5", "object-path-immutable": "^4.1.2",
"json-schema-form-react": "^0.0.2", "radix-ui": "^1.1.2",
"@uiw/react-codemirror": "^4.23.6", "json-schema-to-ts": "^3.1.1",
"@codemirror/lang-html": "^6.4.9", "swr": "^2.2.5"
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@xyflow/react": "^12.3.2",
"@mantine/core": "^7.13.4",
"@hello-pangea/dnd": "^17.0.0"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
@@ -62,7 +66,6 @@
"@hono/zod-validator": "^0.4.1", "@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2", "@rjsf/core": "^5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
@@ -148,6 +151,10 @@
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs" "require": "./dist/adapter/cloudflare/index.cjs"
}, },
"./adapter": {
"types": "./dist/types/adapter/index.d.ts",
"import": "./dist/adapter/index.js"
},
"./adapter/vite": { "./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.ts", "types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js", "import": "./dist/adapter/vite/index.js",

View File

@@ -17,13 +17,21 @@ declare global {
} }
export type ApiOptions = { export type ApiOptions = {
host: string; host?: string;
user?: TApiUser;
token?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
localStorage?: boolean; localStorage?: boolean;
}; fetcher?: typeof fetch;
verified?: boolean;
} & (
| {
token?: string;
user?: TApiUser;
}
| {
request: Request;
}
);
export type AuthState = { export type AuthState = {
token?: string; token?: string;
@@ -42,14 +50,26 @@ export class Api {
public auth!: AuthApi; public auth!: AuthApi;
public media!: MediaApi; public media!: MediaApi;
constructor(private readonly options: ApiOptions) { constructor(private options: ApiOptions = {}) {
if (options.user) { // only mark verified if forced
this.user = options.user; this.verified = options.verified === true;
this.token_transport = "none";
this.verified = true; // prefer request if given
} else if (options.token) { if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(options.token); this.updateToken(options.token);
// then check for an user object
} else if ("user" in options) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
} else { } else {
this.extractToken(); this.extractToken();
} }
@@ -58,7 +78,7 @@ export class Api {
} }
get baseUrl() { get baseUrl() {
return this.options.host; return this.options.host ?? "http://localhost";
} }
get tokenKey() { get tokenKey() {
@@ -66,13 +86,15 @@ export class Api {
} }
private extractToken() { private extractToken() {
// if token has to be extracted, it's never verified
this.verified = false;
if (this.options.headers) { if (this.options.headers) {
// try cookies // try cookies
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
if (cookieToken) { if (cookieToken) {
this.updateToken(cookieToken);
this.token_transport = "cookie"; this.token_transport = "cookie";
this.verified = true; this.updateToken(cookieToken);
return; return;
} }
@@ -96,6 +118,8 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) { updateToken(token?: string, rebuild?: boolean) {
this.token = token; this.token = token;
this.verified = false;
if (token) { if (token) {
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else { } else {
@@ -115,11 +139,15 @@ export class Api {
if (rebuild) this.buildApis(); if (rebuild) this.buildApis();
} }
markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
this.verified = verfied; this.verified = verfied;
return this; return this;
} }
isAuthVerified(): boolean {
return this.verified;
}
getAuthState(): AuthState { getAuthState(): AuthState {
return { return {
token: this.token, token: this.token,
@@ -128,6 +156,11 @@ export class Api {
}; };
} }
isAuthenticated(): boolean {
const { token, user } = this.getAuthState();
return !!token && !!user;
}
async getVerifiedAuthState(): Promise<AuthState> { async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth(); await this.verifyAuth();
return this.getAuthState(); return this.getAuthState();
@@ -140,11 +173,13 @@ export class Api {
} }
try { try {
const res = await this.auth.me(); const { ok, data } = await this.auth.me();
if (!res.ok || !res.body.user) { const user = data?.user;
if (!ok || !user) {
throw new Error(); throw new Error();
} }
this.user = user;
this.markAuthVerified(true); this.markAuthVerified(true);
} catch (e) { } catch (e) {
this.markAuthVerified(false); this.markAuthVerified(false);
@@ -156,21 +191,29 @@ export class Api {
return this.user || null; return this.user || null;
} }
private buildApis() { getParams() {
const baseParams = { return Object.freeze({
host: this.options.host, host: this.baseUrl,
token: this.token, token: this.token,
headers: this.options.headers, headers: this.options.headers,
token_transport: this.token_transport token_transport: this.token_transport
};
this.system = new SystemApi(baseParams);
this.data = new DataApi(baseParams);
this.auth = new AuthApi({
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
}); });
this.media = new MediaApi(baseParams); }
private buildApis() {
const baseParams = this.getParams();
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
{
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
},
fetcher
);
this.media = new MediaApi(baseParams, fetcher);
} }
} }

View File

@@ -1,6 +1,7 @@
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { Event } from "core/events"; import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import { import {
type InitialModuleConfigs, type InitialModuleConfigs,
ModuleManager, ModuleManager,
@@ -132,7 +133,7 @@ export class App {
return this.modules.ctx().em; return this.modules.ctx().em;
} }
get fetch(): any { get fetch(): Hono["fetch"] {
return this.server.fetch; return this.server.fetch;
} }
@@ -155,6 +156,10 @@ export class App {
return this.modules.version(); return this.modules.version();
} }
isBuilt(): boolean {
return this.modules.isBuilt();
}
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.adminController = new AdminController(this, config); this.adminController = new AdminController(this, config);

View File

@@ -1,7 +1,8 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter"; import type { App } from "bknd";
import { Api, type ApiOptions, type App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig = FrameworkBkndConfig; export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
type TAstro = { type TAstro = {
request: Request; request: Request;
@@ -13,18 +14,20 @@ export type Options = {
host?: string; host?: string;
}; };
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) { export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
return new Api({ const api = new Api({
host: new URL(Astro.request.url).origin, host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined headers: options.mode === "dynamic" ? Astro.request.headers : undefined
}); });
await api.verifyAuth();
return api;
} }
let app: App; let app: App;
export function serve(config: AstroBkndConfig = {}) { export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
return async (args: TAstro) => { return async (args: Context) => {
if (!app) { if (!app) {
app = await createFrameworkApp(config); app = await createFrameworkApp(config, args);
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };

View File

@@ -2,10 +2,11 @@
import path from "node:path"; import path from "node:path";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core";
import type { ServeOptions } from "bun"; import type { ServeOptions } from "bun";
import { config } from "core";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
let app: App; let app: App;
@@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {})
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) { if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({ app = await createRuntimeApp({
...config, ...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root }) serveStatic: serveStatic({ root })
}); });
} }

View File

@@ -1,18 +1,17 @@
import type { CreateAppConfig } from "bknd"; import type { FrameworkBkndConfig } from "bknd/adapter";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import type { FrameworkBkndConfig } from "../index";
import { getCached } from "./modes/cached"; import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable"; import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh"; import { getFresh, getWarm } from "./modes/fresh";
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & { export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (env: Env) => { bindings?: (args: Context<Env>) => {
kv?: KVNamespace; kv?: KVNamespace;
dobj?: DurableObjectNamespace; dobj?: DurableObjectNamespace;
}; };
static?: "kv" | "assets";
key?: string; key?: string;
keepAliveSeconds?: number; keepAliveSeconds?: number;
forceHttps?: boolean; forceHttps?: boolean;
@@ -21,28 +20,33 @@ export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> &
html?: string; html?: string;
}; };
export type Context = { export type Context<Env = any> = {
request: Request; request: Request;
env: any; env: Env;
ctx: ExecutionContext; ctx: ExecutionContext;
}; };
export function serve(config: CloudflareBkndConfig) { export function serve<Env = any>(config: CloudflareBkndConfig<Env>) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url); const url = new URL(request.url);
const manifest = config.manifest;
if (manifest) { if (config.manifest && config.static === "assets") {
console.warn("manifest is not useful with static 'assets'");
} else if (!config.manifest && config.static === "kv") {
throw new Error("manifest is required with static 'kv'");
}
if (config.manifest && config.static !== "assets") {
const pathname = url.pathname.slice(1); const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(manifest); const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) { if (pathname && pathname in assetManifest) {
const hono = new Hono(); const hono = new Hono();
hono.all("*", async (c, next) => { hono.all("*", async (c, next) => {
const res = await serveStatic({ const res = await serveStatic({
path: `./${pathname}`, path: `./${pathname}`,
manifest manifest: config.manifest!
})(c as any, next); })(c as any, next);
if (res instanceof Response) { if (res instanceof Response) {
const ttl = 60 * 60 * 24 * 365; const ttl = 60 * 60 * 24 * 365;

View File

@@ -1,8 +1,8 @@
import { createRuntimeApp } from "adapter";
import { App } from "bknd"; import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index"; import type { CloudflareBkndConfig, Context } from "../index";
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) { export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
const { kv } = config.bindings?.(env)!; const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app"; const key = config.key ?? "app";
@@ -37,7 +37,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
}, },
adminOptions: { html: config.html } adminOptions: { html: config.html }
}, },
env { env, ctx, ...args }
); );
if (!cachedConfig) { if (!cachedConfig) {

View File

@@ -1,7 +1,7 @@
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
import { createRuntimeApp } from "adapter";
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
import type { App, CreateAppConfig } from "bknd"; import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const { dobj } = config.bindings?.(ctx.env)!; const { dobj } = config.bindings?.(ctx.env)!;
@@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const id = dobj.idFromName(key); const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp; const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app; const create_config = makeConfig(config, ctx);
const res = await stub.fire(ctx.request, { const res = await stub.fire(ctx.request, {
config: create_config, config: create_config,

View File

@@ -1,14 +1,14 @@
import { createRuntimeApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index"; import type { CloudflareBkndConfig, Context } from "../index";
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) { export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
adminOptions: config.html ? { html: config.html } : undefined adminOptions: config.html ? { html: config.html } : undefined
}, },
env ctx
); );
} }

View File

@@ -1,59 +1,29 @@
import type { IncomingMessage } from "node:http"; import { App, type CreateAppConfig } from "bknd";
import { App, type CreateAppConfig, registries } from "bknd"; import { config as $config } from "bknd/core";
import { config as $config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
export type BkndConfig<Env = any> = CreateAppConfig & { export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((env: Env) => CreateAppConfig); app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>; beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
}; };
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>; export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & { export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string; distPath?: string;
}; };
export function nodeRequestToRequest(req: IncomingMessage): Request { export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
let protocol = "http";
try {
protocol = req.headers["x-forwarded-proto"] as string;
} catch (e) {}
const host = req.headers.host;
const url = `${protocol}://${host}${req.url}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
headers.append(key, value.join(", "));
} else if (value) {
headers.append(key, value);
}
}
const method = req.method || "GET";
return new Request(url, {
method,
headers
});
}
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
let additionalConfig: CreateAppConfig = {}; let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) { if ("app" in config && config.app) {
if (typeof config.app === "function") { if (typeof config.app === "function") {
if (!env) { if (!args) {
throw new Error("env is required when config.app is a function"); throw new Error("args is required when config.app is a function");
} }
additionalConfig = config.app(env); additionalConfig = config.app(args);
} else { } else {
additionalConfig = config.app; additionalConfig = config.app;
} }
@@ -62,11 +32,11 @@ export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): Creat
return { ...config, ...additionalConfig }; return { ...config, ...additionalConfig };
} }
export async function createFrameworkApp<Env = any>( export async function createFrameworkApp<Args = any>(
config: FrameworkBkndConfig, config: FrameworkBkndConfig,
env?: Env args?: Args
): Promise<App> { ): Promise<App> {
const app = App.create(makeConfig(config, env)); const app = App.create(makeConfig(config, args));
if (config.onBuilt) { if (config.onBuilt) {
app.emgr.onEvent( app.emgr.onEvent(
@@ -87,20 +57,14 @@ export async function createFrameworkApp<Env = any>(
export async function createRuntimeApp<Env = any>( export async function createRuntimeApp<Env = any>(
{ {
serveStatic, serveStatic,
registerLocalMedia,
adminOptions, adminOptions,
...config ...config
}: RuntimeBkndConfig & { }: RuntimeBkndConfig & {
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
registerLocalMedia?: boolean;
adminOptions?: AdminControllerOptions | false; adminOptions?: AdminControllerOptions | false;
}, },
env?: Env env?: Env
): Promise<App> { ): Promise<App> {
if (registerLocalMedia) {
registerLocalMediaAdapter();
}
const app = App.create(makeConfig(config, env)); const app = App.create(makeConfig(config, env));
app.emgr.onEvent( app.emgr.onEvent(

View File

@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Api, type App } from "bknd"; import { nodeRequestToRequest } from "adapter/utils";
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type NextjsBkndConfig = FrameworkBkndConfig & { export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanSearch?: string[]; cleanSearch?: string[];
@@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) {
} }
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) { export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
return (ctx: GetServerSidePropsContext & { api: Api }) => { return async (ctx: GetServerSidePropsContext & { api: Api }) => {
return handler({ ...ctx, api: createApi(ctx) }); const api = createApi(ctx);
await api.verifyAuth();
return handler({ ...ctx, api });
}; };
} }

View File

@@ -1,6 +1,12 @@
export * from "./node.adapter"; import { registries } from "bknd";
export { import {
StorageLocalAdapter, type LocalAdapterConfig,
type LocalAdapterConfig StorageLocalAdapter
} from "../../media/storage/adapters/StorageLocalAdapter"; } from "../../media/storage/adapters/StorageLocalAdapter";
export { registerLocalMediaAdapter } from "../index";
export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig };
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}

View File

@@ -1,9 +1,10 @@
import path from "node:path"; import path from "node:path";
import { serve as honoServe } from "@hono/node-server"; import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index";
import type { App } from "bknd"; import type { App } from "bknd";
import { config as $config } from "core"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index"; import { config as $config } from "bknd/core";
export type NodeBkndConfig = RuntimeBkndConfig & { export type NodeBkndConfig = RuntimeBkndConfig & {
port?: number; port?: number;
@@ -37,9 +38,9 @@ export function serve({
hostname, hostname,
fetch: async (req: Request) => { fetch: async (req: Request) => {
if (!app) { if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({ app = await createRuntimeApp({
...config, ...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root }) serveStatic: serveStatic({ root })
}); });
} }

View File

@@ -1,9 +1,11 @@
import { useAuth } from "bknd/client";
import type { BkndAdminProps } from "bknd/ui"; import type { BkndAdminProps } from "bknd/ui";
import { Suspense, lazy, useEffect, useState } from "react"; import { Suspense, lazy, useEffect, useState } from "react";
export function adminPage(props?: BkndAdminProps) { export function adminPage(props?: BkndAdminProps) {
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin }))); const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
return () => { return () => {
const auth = useAuth();
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
return ( return (
<Suspense> <Suspense>
<Admin {...props} /> <Admin withProvider={{ user: auth.user }} {...props} />
</Suspense> </Suspense>
); );
}; };

View File

@@ -1,14 +1,37 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type RemixBkndConfig = FrameworkBkndConfig; export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
type RemixContext = {
request: Request;
};
let app: App; let app: App;
export function serve(config: RemixBkndConfig = {}) { export function serve<Args extends RemixContext = RemixContext>(
return async (args: { request: Request }) => { config: RemixBkndConfig<Args> = {}
) {
return async (args: Args) => {
if (!app) { if (!app) {
app = await createFrameworkApp(config); app = await createFrameworkApp(config, args);
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };
} }
export function withApi<Args extends { request: Request; context: { api: Api } }, R>(
handler: (args: Args, api: Api) => Promise<R>
) {
return async (args: Args) => {
if (!args.context.api) {
args.context.api = new Api({
host: new URL(args.request.url).origin,
headers: args.request.headers
});
await args.context.api.verifyAuth();
}
return handler(args, args.context.api);
};
}

25
app/src/adapter/utils.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { IncomingMessage } from "node:http";
export function nodeRequestToRequest(req: IncomingMessage): Request {
let protocol = "http";
try {
protocol = req.headers["x-forwarded-proto"] as string;
} catch (e) {}
const host = req.headers.host;
const url = `${protocol}://${host}${req.url}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
headers.append(key, value.join(", "));
} else if (value) {
headers.append(key, value);
}
}
const method = req.method || "GET";
return new Request(url, {
method,
headers
});
}

View File

@@ -1,7 +1,8 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & { export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
@@ -28,10 +29,10 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
} }
async function createApp(config: ViteBkndConfig = {}, env?: any) { async function createApp(config: ViteBkndConfig = {}, env?: any) {
registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
registerLocalMedia: true,
adminOptions: adminOptions:
config.setAdminHtml === false config.setAdminHtml === false
? undefined ? undefined

View File

@@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input); const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include"
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
@@ -23,7 +26,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input); const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include"
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }

View File

@@ -21,6 +21,15 @@ export class AuthController extends Controller {
return this.auth.ctx.guard; return this.auth.ctx.guard;
} }
get em() {
return this.auth.ctx.em;
}
get userRepo() {
const entity_name = this.auth.config.entity_name;
return this.em.repo(entity_name as "users");
}
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) { private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.(); const actions = strategy.getActions?.();
if (!actions) { if (!actions) {
@@ -96,7 +105,10 @@ export class AuthController extends Controller {
hono.get("/me", auth(), async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: this.auth.authenticator.getUser() }); const claims = this.auth.authenticator.getUser()!;
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
} }
return c.json({ user: null }, 403); return c.json({ user: null }, 403);

View File

@@ -259,7 +259,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
async requestCookieRefresh(c: Context) { async requestCookieRefresh(c: Context) {
if (this.config.cookie.renew) { if (this.config.cookie.renew && this.isUserLoggedIn()) {
const token = await this.getAuthCookie(c); const token = await this.getAuthCookie(c);
if (token) { if (token) {
await this.setAuthCookie(c, token); await this.setAuthCookie(c, token);
@@ -299,8 +299,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
private getSuccessPath(c: Context) { private getSafeUrl(c: Context, path: string) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); const p = path.replace(/\/+$/, "/");
// nextjs doesn't support non-fq urls // nextjs doesn't support non-fq urls
// but env could be proxied (stackblitz), so we shouldn't fq every url // but env could be proxied (stackblitz), so we shouldn't fq every url
@@ -312,21 +312,26 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
if (this.isJsonRequest(c)) { const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
return c.json(data);
}
const successUrl = this.getSuccessPath(c);
const referer = redirect ?? c.req.header("Referer") ?? successUrl; const referer = redirect ?? c.req.header("Referer") ?? successUrl;
//console.log("auth respond", { redirect, successUrl, successPath }); //console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) { if ("token" in data) {
await this.setAuthCookie(c, data.token); await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c)) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs // can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl); //console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl); return c.redirect(successUrl);
} }
if (this.isJsonRequest(c)) {
return c.json(data, 400);
}
let message = "An error occured"; let message = "An error occured";
if (data instanceof Exception) { if (data instanceof Exception) {
message = data.message; message = data.message;

View File

@@ -1,4 +1,5 @@
import type { Authenticator, Strategy } from "auth"; import type { Authenticator, Strategy } from "auth";
import { isDebug, tbValidator as tb } from "core";
import { type Static, StringEnum, Type, parse } from "core/utils"; import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils"; import { hash } from "core/utils";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
@@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy {
const hono = new Hono(); const hono = new Hono();
return hono return hono
.post("/login", async (c) => { .post(
const body = await authenticator.getBody(c); "/login",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String())
})
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
try { try {
const payload = await this.login(body); const payload = await this.login(body);
const data = await authenticator.resolve("login", this, payload.password, payload); const data = await authenticator.resolve(
"login",
this,
payload.password,
payload
);
return await authenticator.respond(c, data); return await authenticator.respond(c, data, redirect);
} catch (e) { } catch (e) {
return await authenticator.respond(c, e); return await authenticator.respond(c, e);
}
} }
}) )
.post("/register", async (c) => { .post(
const body = await authenticator.getBody(c); "/register",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String())
})
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
const payload = await this.register(body); const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload); const data = await authenticator.resolve(
"register",
this,
payload.password,
payload
);
return await authenticator.respond(c, data); return await authenticator.respond(c, data, redirect);
}); }
);
} }
getActions(): StrategyActions { getActions(): StrategyActions {

View File

@@ -12,6 +12,20 @@ export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object"; return value !== null && typeof value === "object";
} }
export function omitKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[]
): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) {
(result as any)[key] = value;
}
}
return result;
}
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T { export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
try { try {
@@ -266,3 +280,82 @@ export function mergeObjectWith(object, source, customizer) {
return object; return object;
} }
export function isEqual(value1: any, value2: any): boolean {
// Each type corresponds to a particular comparison algorithm
const getType = (value: any) => {
if (value !== Object(value)) return "primitive";
if (Array.isArray(value)) return "array";
if (value instanceof Map) return "map";
if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value)))
return "plainObject";
if (value instanceof Function) return "function";
throw new Error(
`deeply comparing an instance of type ${value1.constructor?.name} is not supported.`
);
};
const type = getType(value1);
if (type !== getType(value2)) {
return false;
}
if (type === "primitive") {
return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2));
} else if (type === "array") {
return (
value1.length === value2.length &&
value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i]))
);
} else if (type === "map") {
// In this particular implementation, map keys are not
// being deeply compared, only map values.
return (
value1.size === value2.size &&
[...value1].every(([iterKey, iterValue]) => {
return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey));
})
);
} else if (type === "plainObject") {
const value1AsMap = new Map(Object.entries(value1));
const value2AsMap = new Map(Object.entries(value2));
return (
value1AsMap.size === value2AsMap.size &&
[...value1AsMap].every(([iterKey, iterValue]) => {
return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey));
})
);
} else if (type === "function") {
// just check signature
return value1.toString() === value2.toString();
} else {
throw new Error("Unreachable");
}
}
export function getPath(
object: object,
_path: string | (string | number)[],
defaultValue = undefined
): any {
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
if (path.length === 0) {
return object;
}
try {
const [head, ...tail] = path;
if (!head || !(head in object)) {
return defaultValue;
}
return getPath(object[head], tail, defaultValue);
} catch (error) {
if (typeof defaultValue !== "undefined") {
return defaultValue;
}
throw new Error(`Invalid path: ${path.join(".")}`);
}
}

View File

@@ -81,9 +81,12 @@ export function identifierToHumanReadable(str: string) {
case "SCREAMING_SNAKE_CASE": case "SCREAMING_SNAKE_CASE":
return snakeToPascalWithSpaces(str.toLowerCase()); return snakeToPascalWithSpaces(str.toLowerCase());
case "unknown": case "unknown":
return str; return ucFirst(str);
} }
} }
export function autoFormatString(str: string) {
return identifierToHumanReadable(str);
}
export function kebabToPascalWithSpaces(str: string): string { export function kebabToPascalWithSpaces(str: string): string {
return str.split("-").map(ucFirst).join(" "); return str.split("-").map(ucFirst).join(" ");

View File

@@ -21,7 +21,6 @@ import {
type ValueErrorIterator type ValueErrorIterator
} from "@sinclair/typebox/errors"; } from "@sinclair/typebox/errors";
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value"; import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
import { cloneDeep } from "lodash-es";
export type RecursivePartial<T> = { export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[] [P in keyof T]?: T[P] extends (infer U)[]
@@ -73,7 +72,7 @@ export class TypeInvalidError extends Error {
} }
export function stripMark<O = any>(obj: O) { export function stripMark<O = any>(obj: O) {
const newObj = cloneDeep(obj); const newObj = structuredClone(obj);
mark(newObj, false); mark(newObj, false);
return newObj as O; return newObj as O;
} }

View File

@@ -5,7 +5,8 @@ import {
type StaticDecode, type StaticDecode,
StringEnum, StringEnum,
Type, Type,
Value Value,
isObject
} from "core/utils"; } from "core/utils";
import { WhereBuilder, type WhereQuery } from "../entities"; import { WhereBuilder, type WhereQuery } from "../entities";
@@ -71,22 +72,51 @@ export type RepoWithSchema = Record<
>; >;
export const withSchema = <TSelf extends TThis>(Self: TSelf) => export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)])) Type.Transform(
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)])
)
.Decode((value) => { .Decode((value) => {
let _value = typeof value === "string" ? [value] : value; // images
// images,comments
// ["images","comments"]
// { "images": {} }
if (Array.isArray(value)) { if (!Array.isArray(value) && isObject(value)) {
if (!value.every((v) => typeof v === "string")) { console.log("is object");
throw new Error("Invalid 'with' schema"); return value as RepoWithSchema;
}
_value = value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
} }
return _value as RepoWithSchema; let _value: any = null;
if (typeof value === "string") {
// if stringified object
if (value.match(/^\{/)) {
return JSON.parse(value) as RepoWithSchema;
}
// if stringified array
if (value.match(/^\[/)) {
_value = JSON.parse(value) as string[];
// if comma-separated string
} else if (value.includes(",")) {
_value = value.split(",");
// if single string
} else {
_value = [value];
}
} else if (Array.isArray(value)) {
_value = value;
}
if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) {
throw new Error("Invalid 'with' schema");
}
return _value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
}) })
.Encode((value) => value); .Encode((value) => value);
@@ -117,7 +147,7 @@ export type RepoQueryIn = {
offset?: number; offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" }; sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[]; select?: string[];
with?: string[] | Record<string, RepoQueryIn>; with?: string | string[] | Record<string, RepoQueryIn>;
join?: string[]; join?: string[];
where?: WhereQuery; where?: WhereQuery;
}; };

View File

@@ -12,8 +12,5 @@ export {
export * as middlewares from "modules/middlewares"; export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries"; export { registries } from "modules/registries";
export type * from "./adapter";
export { Api, type ApiOptions } from "./Api";
export type { MediaFieldSchema } from "media/AppMedia"; export type { MediaFieldSchema } from "media/AppMedia";
export type { UserFieldSchema } from "auth/AppAuth"; export type { UserFieldSchema } from "auth/AppAuth";

View File

@@ -16,7 +16,8 @@ export function buildMediaSchema() {
config: adapter.schema config: adapter.schema
}, },
{ {
title: name, title: adapter.schema.title ?? name,
description: adapter.schema.description,
additionalProperties: false additionalProperties: false
} }
); );

View File

@@ -9,7 +9,7 @@ export const cloudinaryAdapterConfig = Type.Object(
api_secret: Type.String(), api_secret: Type.String(),
upload_preset: Type.Optional(Type.String()) upload_preset: Type.Optional(Type.String())
}, },
{ title: "Cloudinary" } { title: "Cloudinary", description: "Cloudinary media storage" }
); );
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>; export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;

View File

@@ -7,7 +7,7 @@ export const localAdapterConfig = Type.Object(
{ {
path: Type.String({ default: "./" }) path: Type.String({ default: "./" })
}, },
{ title: "Local" } { title: "Local", description: "Local file system storage" }
); );
export type LocalAdapterConfig = Static<typeof localAdapterConfig>; export type LocalAdapterConfig = Static<typeof localAdapterConfig>;

View File

@@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object(
}) })
}, },
{ {
title: "S3" title: "AWS S3",
description: "AWS S3 or compatible storage"
} }
); );

View File

@@ -23,9 +23,10 @@ export type ApiResponse<Data = any> = {
export type TInput = string | (string | number | PrimaryFieldType)[]; export type TInput = string | (string | number | PrimaryFieldType)[];
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> { export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> {
protected fetcher?: typeof fetch; constructor(
protected readonly _options: Partial<Options> = {},
constructor(protected readonly _options: Partial<Options> = {}) {} protected fetcher?: typeof fetch
) {}
protected getDefaultOptions(): Partial<Options> { protected getDefaultOptions(): Partial<Options> {
return {}; return {};

View File

@@ -68,6 +68,12 @@ export type InitialModuleConfigs =
} & ModuleConfigs) } & ModuleConfigs)
| PartialRec<ModuleConfigs>; | PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
error = 1,
log = 2
}
export type ModuleManagerOptions = { export type ModuleManagerOptions = {
initial?: InitialModuleConfigs; initial?: InitialModuleConfigs;
eventManager?: EventManager<any>; eventManager?: EventManager<any>;
@@ -85,6 +91,8 @@ export type ModuleManagerOptions = {
trustFetched?: boolean; trustFetched?: boolean;
// runs when initial config provided on a fresh database // runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>; seed?: (ctx: ModuleBuildContext) => Promise<void>;
// wether
verbosity?: Verbosity;
}; };
type ConfigTable<Json = ModuleConfigs> = { type ConfigTable<Json = ModuleConfigs> = {
@@ -135,7 +143,7 @@ export class ModuleManager {
private _built = false; private _built = false;
private readonly _booted_with?: "provided" | "partial"; private readonly _booted_with?: "provided" | "partial";
private logger = new DebugLogger(false); private logger: DebugLogger;
constructor( constructor(
private readonly connection: Connection, private readonly connection: Connection,
@@ -144,6 +152,7 @@ export class ModuleManager {
this.__em = new EntityManager([__bknd], this.connection); this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules; this.modules = {} as Modules;
this.emgr = new EventManager(); this.emgr = new EventManager();
this.logger = new DebugLogger(this.verbosity === Verbosity.log);
const context = this.ctx(true); const context = this.ctx(true);
let initial = {} as Partial<ModuleConfigs>; let initial = {} as Partial<ModuleConfigs>;
@@ -171,6 +180,14 @@ export class ModuleManager {
} }
} }
private get verbosity() {
return this.options?.verbosity ?? Verbosity.silent;
}
isBuilt(): boolean {
return this._built;
}
/** /**
* This is set through module's setListener * This is set through module's setListener
* It's called everytime a module's config is updated in SchemaObject * It's called everytime a module's config is updated in SchemaObject
@@ -241,20 +258,23 @@ export class ModuleManager {
const startTime = performance.now(); const startTime = performance.now();
// disabling console log, because the table might not exist yet // disabling console log, because the table might not exist yet
const result = await withDisabledConsole(async () => { const result = await withDisabledConsole(
const { data: result } = await this.repo().findOne( async () => {
{ type: "config" }, const { data: result } = await this.repo().findOne(
{ { type: "config" },
sort: { by: "version", dir: "desc" } {
sort: { by: "version", dir: "desc" }
}
);
if (!result) {
throw BkndError.with("no config");
} }
);
if (!result) { return result as unknown as ConfigTable;
throw BkndError.with("no config"); },
} this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"]
);
return result as unknown as ConfigTable;
}, ["log", "error", "warn"]);
this.logger this.logger
.log("took", performance.now() - startTime, "ms", { .log("took", performance.now() - startTime, "ms", {

View File

@@ -70,7 +70,8 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
const obj = { const obj = {
user: auth.authenticator?.getUser(), user: auth.authenticator?.getUser(),
logout_route: this.withBasePath(authRoutes.logout) logout_route: this.withBasePath(authRoutes.logout),
color_scheme: configs.server.admin.color_scheme
}; };
const html = await this.getHtml(obj); const html = await this.getHtml(obj);
if (!html) { if (!html) {
@@ -190,6 +191,10 @@ export class AdminController extends Controller {
/> />
<link rel="icon" href={favicon} type="image/x-icon" /> <link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title> <title>BKND</title>
{/*<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>*/}
{isProd ? ( {isProd ? (
<Fragment> <Fragment>
<script <script

View File

@@ -3,6 +3,7 @@ import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules"; import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd"; import { BkndProvider, useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { FlashMessage } from "ui/modules/server/FlashMessage"; import { FlashMessage } from "ui/modules/server/FlashMessage";
@@ -40,8 +41,7 @@ export default function Admin({
} }
function AdminInternal() { function AdminInternal() {
const b = useBknd(); const { theme } = useTheme();
const theme = b.app.getAdminConfig().color_scheme;
return ( return (
<MantineProvider {...createMantineTheme(theme ?? "light")}> <MantineProvider {...createMantineTheme(theme ?? "light")}>

View File

@@ -1,10 +1,7 @@
import { IconAlertHexagon } from "@tabler/icons-react";
import type { ModuleConfigs, ModuleSchemas } from "modules"; import type { ModuleConfigs, ModuleSchemas } from "modules";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import { useApi } from "ui/client"; import { useApi } from "ui/client";
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
@@ -18,11 +15,18 @@ type BkndContext = {
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;
app: AppReduced; app: AppReduced;
adminOverride?: ModuleConfigs["server"]["admin"]; adminOverride?: ModuleConfigs["server"]["admin"];
fallback: boolean;
}; };
const BkndContext = createContext<BkndContext>(undefined!); const BkndContext = createContext<BkndContext>(undefined!);
export type { TSchemaActions }; export type { TSchemaActions };
enum Fetching {
None = 0,
Schema = 1,
Secrets = 2
}
export function BkndProvider({ export function BkndProvider({
includeSecrets = false, includeSecrets = false,
adminOverride, adminOverride,
@@ -34,10 +38,11 @@ export function BkndProvider({
>) { >) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets); const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] = const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>(); useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
const [fetched, setFetched] = useState(false); const [fetched, setFetched] = useState(false);
const [error, setError] = useState<boolean>(); const [error, setError] = useState<boolean>();
const errorShown = useRef<boolean>(); const errorShown = useRef<boolean>();
const fetching = useRef<Fetching>(Fetching.None);
const [local_version, set_local_version] = useState(0); const [local_version, set_local_version] = useState(0);
const api = useApi(); const api = useApi();
@@ -46,7 +51,12 @@ export function BkndProvider({
} }
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) { async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
const requesting = withSecrets ? Fetching.Secrets : Fetching.Schema;
if (fetching.current === requesting) return;
if (withSecrets && !force) return; if (withSecrets && !force) return;
fetching.current = requesting;
const res = await api.system.readSchema({ const res = await api.system.readSchema({
config: true, config: true,
secrets: _includeSecrets secrets: _includeSecrets
@@ -57,32 +67,35 @@ export function BkndProvider({
errorShown.current = true; errorShown.current = true;
setError(true); setError(true);
//return; // if already has schema, don't overwrite
if (fetched && schema?.schema) return;
} else if (error) { } else if (error) {
setError(false); setError(false);
} }
const schema = res.ok const newSchema = res.ok
? res.body ? res.body
: ({ : ({
version: 0, version: 0,
schema: getDefaultSchema(), schema: getDefaultSchema(),
config: getDefaultConfig(), config: getDefaultConfig(),
permissions: [] permissions: [],
fallback: true
} as any); } as any);
if (adminOverride) { if (adminOverride) {
schema.config.server.admin = { newSchema.config.server.admin = {
...schema.config.server.admin, ...newSchema.config.server.admin,
...adminOverride ...adminOverride
}; };
} }
startTransition(() => { startTransition(() => {
setSchema(schema); setSchema(newSchema);
setWithSecrets(_includeSecrets); setWithSecrets(_includeSecrets);
setFetched(true); setFetched(true);
set_local_version((v) => v + 1); set_local_version((v) => v + 1);
fetching.current = Fetching.None;
}); });
} }

View File

@@ -30,7 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
console.error("error .....", e); console.error("error .....", e);
} }
console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); //console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user }); const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
return ( return (
@@ -60,6 +60,7 @@ export const useBaseUrl = () => {
type BkndWindowContext = { type BkndWindowContext = {
user?: TApiUser; user?: TApiUser;
logout_route: string; logout_route: string;
color_scheme?: "light" | "dark";
}; };
export function useBkndWindowContext(): BkndWindowContext { export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) { if (typeof window !== "undefined" && window.__BKND__) {

View File

@@ -9,4 +9,4 @@ export {
export * from "./api/use-api"; export * from "./api/use-api";
export * from "./api/use-entity"; export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth"; export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api"; export { Api, type TApiUser, type AuthState, type ApiOptions } from "../../Api";

View File

@@ -0,0 +1,22 @@
import type { TAppMediaConfig } from "media/media-schema";
import { useBknd } from "ui/client/BkndProvider";
export function useBkndMedia() {
const { config, schema, actions: bkndActions } = useBknd();
const actions = {
config: {
patch: async (data: Partial<TAppMediaConfig>) => {
return await bkndActions.set("media", data, true);
}
}
};
const $media = {};
return {
$media,
config: config.media,
schema: schema.media,
actions
};
}

View File

@@ -1,8 +1,9 @@
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
export function useBkndSystem() { export function useBkndSystem() {
const { config, schema, actions: bkndActions } = useBknd(); const { config, schema, actions: bkndActions } = useBknd();
const theme = config.server.admin.color_scheme ?? "light"; const { theme } = useTheme();
const actions = { const actions = {
theme: { theme: {

View File

@@ -1,8 +1,18 @@
import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
export function useTheme(): { theme: "light" | "dark" } { export type Theme = "light" | "dark";
const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme as any;
return { theme }; export function useTheme(fallback: Theme = "light"): { theme: Theme } {
const b = useBknd();
const winCtx = useBkndWindowContext();
if (b) {
if (b?.adminOverride?.color_scheme) {
return { theme: b.adminOverride.color_scheme };
} else if (!b.fallback) {
return { theme: b.config.server.admin.color_scheme ?? fallback };
}
}
return { theme: winCtx.color_scheme ?? fallback };
} }

View File

@@ -1,4 +1,5 @@
import type React from "react"; import type React from "react";
import { Children } from "react";
import { forwardRef } from "react"; import { forwardRef } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
@@ -19,7 +20,7 @@ const styles = {
default: "bg-primary/5 hover:bg-primary/10 link text-primary/70", default: "bg-primary/5 hover:bg-primary/10 link text-primary/70",
primary: "bg-primary hover:bg-primary/80 link text-background", primary: "bg-primary hover:bg-primary/80 link text-background",
ghost: "bg-transparent hover:bg-primary/5 link text-primary/70", ghost: "bg-transparent hover:bg-primary/5 link text-primary/70",
outline: "border border-primary/70 bg-transparent hover:bg-primary/5 link text-primary/70", outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80",
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70", red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
subtlered: subtlered:
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link" "dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link"
@@ -50,7 +51,7 @@ const Base = ({
}: BaseProps) => ({ }: BaseProps) => ({
...props, ...props,
className: twMerge( className: twMerge(
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed", "flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]",
sizes[size ?? "default"], sizes[size ?? "default"],
styles[variant ?? "default"], styles[variant ?? "default"],
props.className props.className
@@ -58,7 +59,11 @@ const Base = ({
children: ( children: (
<> <>
{IconLeft && <IconLeft size={iconSize} {...iconProps} />} {IconLeft && <IconLeft size={iconSize} {...iconProps} />}
{children && <span className={twMerge("leading-none", labelClassName)}>{children}</span>} {children && Children.count(children) === 1 ? (
<span className={twMerge("leading-none", labelClassName)}>{children}</span>
) : (
children
)}
{IconRight && <IconRight size={iconSize} {...iconProps} />} {IconRight && <IconRight size={iconSize} {...iconProps} />}
</> </>
) )

View File

@@ -1,4 +1,3 @@
import { IconCopy } from "@tabler/icons-react";
import { TbCopy } from "react-icons/tb"; import { TbCopy } from "react-icons/tb";
import { JsonView } from "react-json-view-lite"; import { JsonView } from "react-json-view-lite";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";

View File

@@ -1,13 +1,13 @@
import { useBknd } from "ui/client/bknd"; import { useTheme } from "ui/client/use-theme";
export function Logo({ export function Logo({
scale = 0.2, scale = 0.2,
fill, fill,
theme = "light" ...props
}: { scale?: number; fill?: string; theme?: string }) { }: { scale?: number; fill?: string; theme?: string }) {
const $bknd = useBknd(); const t = useTheme();
const _theme = theme ?? $bknd?.app?.getAdminConfig().color_scheme ?? "light"; const theme = props.theme ?? t.theme;
const svgFill = fill ? fill : _theme === "light" ? "black" : "white"; const svgFill = fill ? fill : theme === "light" ? "black" : "white";
const dim = { const dim = {
width: Math.round(578 * scale), width: Math.round(578 * scale),

View File

@@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react";
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>( export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => { (props, ref) => {
const [checked, setChecked] = useState(Boolean(props.value)); const [checked, setChecked] = useState(Boolean(props.value ?? props.defaultValue));
useEffect(() => { useEffect(() => {
console.log("value change", props.value);
setChecked(Boolean(props.value)); setChecked(Boolean(props.value));
}, [props.value]); }, [props.value]);

View File

@@ -1,25 +1,41 @@
import { getBrowser } from "core/utils"; import { getBrowser } from "core/utils";
import type { Field } from "data"; import type { Field } from "data";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import { Switch as RadixSwitch } from "radix-ui";
import {
type ChangeEventHandler,
type ComponentPropsWithoutRef,
type ElementType,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState
} from "react";
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb"; import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({ export const Group = <E extends ElementType = "div">({
error, error,
as,
...props ...props
}) => ( }: React.ComponentProps<E> & { error?: boolean; as?: E }) => {
<div const Tag = as || "div";
{...props}
className={twMerge(
"flex flex-col gap-1.5",
error && "text-red-500", return (
props.className <Tag
)} {...props}
/> className={twMerge(
); "flex flex-col gap-1.5",
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
as === "fieldset" && error && "border-red-500",
error && "text-red-500",
props.className
)}
/>
);
};
export const formElementFactory = (element: string, props: any) => { export const formElementFactory = (element: string, props: any) => {
switch (element) { switch (element) {
@@ -34,7 +50,21 @@ export const formElementFactory = (element: string, props: any) => {
} }
}; };
export const Label: React.FC<React.ComponentProps<"label">> = (props) => <label {...props} />; export const Label = <E extends ElementType = "label">({
as,
...props
}: React.ComponentProps<E> & { as?: E }) => {
const Tag = as || "label";
return <Tag {...props} />;
};
export const Help: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
<div {...props} className={twMerge("text-sm text-primary/50", className)} />
);
export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
<div {...props} className={twMerge("text-sm text-red-500", className)} />
);
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({ export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
field, field,
@@ -145,20 +175,81 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
} }
); );
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>( export type SwitchValue = boolean | 1 | 0 | "true" | "false" | "on" | "off";
(props, ref) => ( export const Switch = forwardRef<
<div className="flex w-full relative"> HTMLButtonElement,
<select Pick<
{...props} ComponentPropsWithoutRef<"input">,
ref={ref} "name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
className={twMerge( > & {
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50", value?: SwitchValue;
"appearance-none h-11 w-full", onChange?: (e: { target: { value: boolean } }) => void;
"border-r-8 border-r-transparent", onCheckedChange?: (checked: boolean) => void;
props.className }
)} >(({ type, required, ...props }, ref) => {
return (
<RadixSwitch.Root
className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted border border-primary/10 outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
onCheckedChange={(bool) => {
props.onChange?.({ target: { value: bool } });
}}
{...(props as any)}
checked={
typeof props.checked !== "undefined"
? props.checked
: typeof props.value !== "undefined"
? Boolean(props.value)
: undefined
}
ref={ref}
>
<RadixSwitch.Thumb className="block h-full aspect-square translate-x-0 rounded-full bg-background transition-transform duration-100 will-change-transform border border-muted data-[state=checked]:translate-x-[17px]" />
</RadixSwitch.Root>
);
});
export const Select = forwardRef<
HTMLSelectElement,
React.ComponentProps<"select"> & {
options?: { value: string; label: string }[] | (string | number)[];
}
>(({ children, options, ...props }, ref) => (
<div className="flex w-full relative">
<select
{...props}
ref={ref}
className={twMerge(
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
"appearance-none h-11 w-full",
!props.multiple && "border-r-8 border-r-transparent",
props.className
)}
>
{options ? (
<>
{!props.required && <option value="" />}
{options
.map((o) => {
if (typeof o !== "object") {
return { value: o, label: String(o) };
}
return o;
})
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</>
) : (
children
)}
</select>
{!props.multiple && (
<TbChevronDown
className="absolute right-3 top-0 bottom-0 h-full opacity-70 pointer-events-none"
size={18}
/> />
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} /> )}
</div> </div>
) ));
);

View File

@@ -0,0 +1,148 @@
import { atom, useAtom } from "jotai";
import type { JsonError, JsonSchema } from "json-schema-library";
import { type ChangeEvent, type ReactNode, createContext, useContext, useMemo } from "react";
import { twMerge } from "tailwind-merge";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form";
import { getLabel, getMultiSchemaMatched } from "./utils";
export type AnyOfFieldRootProps = {
path?: string;
schema?: JsonSchema;
children: ReactNode;
};
export type AnyOfFieldContext = {
path: string;
schema: JsonSchema;
schemas?: JsonSchema[];
selectedSchema?: JsonSchema;
selected: number | null;
select: (index: number | null) => void;
options: string[];
errors: JsonError[];
selectSchema: JsonSchema;
};
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
export const useAnyOfContext = () => {
return useContext(AnyOfContext);
};
const selectedAtom = atom<number | null>(null);
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
const {
setValue,
lib,
pointer,
value: { matchedIndex, schemas },
schema
} = useDerivedFieldContext(path, _schema, (ctx) => {
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
return { matchedIndex, schemas };
});
const errors = useFormError(path, { strict: true });
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
const [_selected, setSelected] = useAtom(selectedAtom);
const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
const select = useEvent((index: number | null) => {
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
setSelected(index);
});
const context = useMemo(() => {
const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`);
const selectSchema = {
type: "string",
enum: options
} satisfies JsonSchema;
const selectedSchema = selected !== null ? (schemas[selected] as JsonSchema) : undefined;
return {
options,
selectSchema,
selectedSchema,
schema,
schemas,
selected
};
}, [selected]);
return (
<AnyOfContext.Provider
key={selected}
value={{
...context,
select,
path,
errors
}}
>
{children}
</AnyOfContext.Provider>
);
};
const Select = () => {
const { selected, select, path, schema, options, selectSchema } = useAnyOfContext();
const handleSelect = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const i = e.target.value ? Number(e.target.value) : null;
select(i);
});
const _options = useMemo(() => options.map((label, value) => ({ label, value })), []);
return (
<>
<Formy.Label>{getLabel(path, schema)}</Formy.Label>
<FieldComponent
schema={selectSchema as any}
/* @ts-ignore */
options={_options}
onChange={handleSelect}
value={selected ?? undefined}
className="h-8 py-1"
/>
</>
);
};
// @todo: add local validation for AnyOf fields
const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path, errors } = useAnyOfContext();
if (selected === null) return null;
return (
<FormContextOverride prefix={path} schema={selectedSchema}>
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
</div>
</FormContextOverride>
);
};
export const AnyOf = {
Root,
Select,
Field,
useContext: useAnyOfContext
};
export const AnyOfField = (props: Omit<AnyOfFieldRootProps, "children">) => {
return (
<fieldset>
<AnyOf.Root {...props}>
<legend className="flex flex-row gap-2 items-center py-2">
<AnyOf.Select />
</legend>
<AnyOf.Field />
</AnyOf.Root>
</fieldset>
);
};

View File

@@ -0,0 +1,138 @@
import { IconLibraryPlus, IconTrash } from "@tabler/icons-react";
import type { JsonSchema } from "json-schema-library";
import { memo, useMemo } from "react";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent } from "./Field";
import { FieldWrapper } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
export const ArrayField = ({
path = "",
schema: _schema
}: { path?: string; schema?: JsonSchema }) => {
const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
// if unique items with enum
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
<FieldComponent
required
name={path}
schema={schema.items}
multiple
className="h-auto"
onChange={(e: any) => {
// @ts-ignore
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
setValue(ctx.path, selected);
}}
/>
</FieldWrapper>
);
}
return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
<ArrayIterator name={path}>
{({ value }) =>
value?.map((v, index: number) => (
<ArrayItem key={index} path={path} index={index} schema={schema} />
))
}
</ArrayIterator>
<div className="flex flex-row">
<ArrayAdd path={path} schema={schema} />
</div>
</FieldWrapper>
);
};
const ArrayItem = memo(({ path, index, schema }: any) => {
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => {
return ctx.value?.[index];
});
const itemPath = suffixPath(path, index);
let subschema = schema.items;
const itemsMultiSchema = getMultiSchema(schema.items);
if (itemsMultiSchema) {
const [, , _subschema] = getMultiSchemaMatched(schema.items, value);
subschema = _subschema;
}
const handleUpdate = useEvent((pointer: string, value: any) => {
ctx.setValue(pointer, value);
});
const handleDelete = useEvent((pointer: string) => {
ctx.deleteValue(pointer);
});
const DeleteButton = useMemo(
() => <IconButton Icon={IconTrash} onClick={() => handleDelete(itemPath)} size="sm" />,
[itemPath]
);
return (
<div key={itemPath} className="flex flex-row gap-2">
<FieldComponent
name={itemPath}
schema={subschema!}
value={value}
onChange={(e) => {
handleUpdate(itemPath, coerce(e.target.value, subschema!));
}}
className="w-full"
/>
{DeleteButton}
</div>
);
}, isEqual);
const ArrayIterator = memo(
({ name, children }: any) => {
return children(useFormValue(name));
},
(prev, next) => prev.value?.length === next.value?.length
);
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
const {
setValue,
value: { currentIndex },
...ctx
} = useDerivedFieldContext(path, schema, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 };
});
const itemsMultiSchema = getMultiSchema(schema.items);
function handleAdd(template?: any) {
const newPath = suffixPath(path, currentIndex);
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
}
if (itemsMultiSchema) {
return (
<Dropdown
dropdownWrapperProps={{
className: "min-w-0"
}}
items={itemsMultiSchema.map((s, i) => ({
label: s!.title ?? `Option ${i + 1}`,
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!))
}))}
onClickItem={console.log}
>
<Button IconLeft={IconLibraryPlus}>Add</Button>
</Dropdown>
);
}
return <Button onClick={() => handleAdd()}>Add</Button>;
};

View File

@@ -0,0 +1,141 @@
import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField";
import { FieldWrapper } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { ObjectField } from "./ObjectField";
import { coerce, isType, isTypeSchema } from "./utils";
export type FieldProps = {
name: string;
schema?: JsonSchema;
onChange?: (e: ChangeEvent<any>) => void;
label?: string | false;
hidden?: boolean;
};
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
const schema = _schema ?? ctx.schema;
if (!isTypeSchema(schema))
return (
<Pre>
[Field] {path} has no schema ({JSON.stringify(schema)})
</Pre>
);
if (isType(schema.type, "object")) {
return <ObjectField path={name} schema={schema} />;
}
if (isType(schema.type, "array")) {
return <ArrayField path={name} schema={schema} />;
}
const disabled = schema.readOnly ?? "const" in schema ?? false;
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const value = coerce(e.target.value, schema as any, { required });
if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) {
ctx.deleteValue(path);
} else {
setValue(path, value);
}
});
return (
<FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}>
<FieldComponent
schema={schema}
name={name}
required={required}
disabled={disabled}
onChange={onChange ?? handleChange}
/>
</FieldWrapper>
);
};
export const Pre = ({ children }) => (
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5 text-wrap whitespace-break-spaces break-all">
{children}
</pre>
);
export const FieldComponent = ({
schema,
..._props
}: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => {
const { value } = useFormValue(_props.name!, { strict: true });
if (!isTypeSchema(schema)) return null;
const props = {
..._props,
// allow override
value: typeof _props.value !== "undefined" ? _props.value : value
};
if (schema.enum) {
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
}
if (isType(schema.type, ["number", "integer"])) {
const additional = {
min: schema.minimum,
max: schema.maximum,
step: schema.multipleOf
};
return (
<Formy.Input
type="number"
id={props.name}
{...props}
value={props.value ?? ""}
{...additional}
/>
);
}
if (isType(schema.type, "boolean")) {
return <Formy.Switch id={props.name} {...(props as any)} checked={value === true} />;
}
if (isType(schema.type, "string") && schema.format === "date-time") {
const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
return (
<Formy.DateInput
id={props.name}
{...props}
value={value}
type="datetime-local"
onChange={(e) => {
const date = new Date(e.target.value);
props.onChange?.({
// @ts-ignore
target: { value: date.toISOString() }
});
}}
/>
);
}
if (isType(schema.type, "string") && schema.format === "date") {
return <Formy.DateInput id={props.name} {...props} value={props.value ?? ""} />;
}
const additional = {
maxLength: schema.maxLength,
minLength: schema.minLength,
pattern: schema.pattern
} as any;
if (schema.format) {
if (["password", "hidden", "url", "email", "tel"].includes(schema.format)) {
additional.type = schema.format;
}
}
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} {...additional} />;
};

View File

@@ -0,0 +1,124 @@
import { IconBug } from "@tabler/icons-react";
import type { JsonSchema } from "json-schema-library";
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
import * as Formy from "ui/components/form/Formy";
import {
useFormContext,
useFormError,
useFormValue
} from "ui/components/form/json-schema-form/Form";
import { Popover } from "ui/components/overlay/Popover";
import { getLabel } from "./utils";
export type FieldwrapperProps = {
name: string;
label?: string | false;
required?: boolean;
schema?: JsonSchema;
debug?: object | boolean;
wrapper?: "group" | "fieldset";
hidden?: boolean;
children: ReactElement | ReactNode;
errorPlacement?: "top" | "bottom";
};
export function FieldWrapper({
name,
label: _label,
required,
schema,
wrapper,
hidden,
errorPlacement = "bottom",
children
}: FieldwrapperProps) {
const errors = useFormError(name, { strict: true });
const examples = schema?.examples || [];
const examplesId = `${name}-examples`;
const description = schema?.description;
const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
const Errors = errors.length > 0 && (
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
);
return (
<Formy.Group
error={errors.length > 0}
as={wrapper === "fieldset" ? "fieldset" : "div"}
className={hidden ? "hidden" : "relative"}
>
{errorPlacement === "top" && Errors}
<FieldDebug name={name} schema={schema} required={required} />
{label && (
<Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={name}
className="self-start"
>
{label} {required && <span className="font-medium opacity-30">*</span>}
</Formy.Label>
)}
<div className="flex flex-row gap-2">
<div className="flex flex-1 flex-col gap-3">
{Children.count(children) === 1 && isValidElement(children)
? cloneElement(children, {
// @ts-ignore
list: examples.length > 0 ? examplesId : undefined
})
: children}
{examples.length > 0 && (
<datalist id={examplesId}>
{examples.map((e, i) => (
<option key={i} value={e as any} />
))}
</datalist>
)}
</div>
</div>
{description && <Formy.Help>{description}</Formy.Help>}
{errorPlacement === "bottom" && Errors}
</Formy.Group>
);
}
const FieldDebug = ({
name,
schema,
required
}: Pick<FieldwrapperProps, "name" | "schema" | "required">) => {
const { options } = useFormContext();
if (!options?.debug) return null;
const { value } = useFormValue(name);
const errors = useFormError(name, { strict: true });
return (
<div className="absolute top-0 right-0">
<Popover
overlayProps={{
className: "max-w-none"
}}
position="bottom-end"
target={({ toggle }) => (
<JsonViewer
className="bg-background pr-3 text-sm"
json={{
name,
value,
required,
schema,
errors
}}
expand={6}
/>
)}
>
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
</Popover>
</div>
);
};

View File

@@ -0,0 +1,378 @@
import {
type PrimitiveAtom,
atom,
getDefaultStore,
useAtom,
useAtomValue,
useSetAtom
} from "jotai";
import { selectAtom } from "jotai/utils";
import { Draft2019, type JsonError, type JsonSchema as LibJsonSchema } from "json-schema-library";
import type { TemplateOptions as LibTemplateOptions } from "json-schema-library/dist/lib/getTemplate";
import type { JSONSchema as $JSONSchema, FromSchema } from "json-schema-to-ts";
import * as immutable from "object-path-immutable";
import {
type ComponentPropsWithoutRef,
type FormEvent,
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef
} from "react";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { useEvent } from "ui/hooks/use-event";
import { Field } from "./Field";
import {
getPath,
isEqual,
isRequired,
omitSchema,
pathToPointer,
prefixPath,
prefixPointer
} from "./utils";
type JSONSchema = Exclude<$JSONSchema, boolean>;
type FormState<Data = any> = {
dirty: boolean;
submitting: boolean;
errors: JsonError[];
data: Data;
};
type FormOptions = {
debug?: boolean;
keepEmpty?: boolean;
};
export type FormContext<Data> = {
setData: (data: Data) => void;
setValue: (pointer: string, value: any) => void;
deleteValue: (pointer: string) => void;
errors: JsonError[];
dirty: boolean;
submitting: boolean;
schema: LibJsonSchema;
lib: Draft2019;
options: FormOptions;
root: string;
_formStateAtom: PrimitiveAtom<FormState<Data>>;
};
const FormContext = createContext<FormContext<any>>(undefined!);
FormContext.displayName = "FormContext";
export function Form<
const Schema extends JSONSchema,
const Data = Schema extends JSONSchema ? FromSchema<Schema> : any
>({
schema: _schema,
initialValues: _initialValues,
initialOpts,
children,
onChange,
onSubmit,
onInvalidSubmit,
validateOn = "submit",
hiddenSubmit = true,
ignoreKeys = [],
options = {},
...props
}: Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
schema: Schema;
validateOn?: "change" | "submit";
initialOpts?: LibTemplateOptions;
ignoreKeys?: string[];
onChange?: (data: Partial<Data>, name: string, value: any) => void;
onSubmit?: (data: Data) => void | Promise<void>;
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
hiddenSubmit?: boolean;
options?: FormOptions;
initialValues?: Schema extends JSONSchema ? FromSchema<Schema> : never;
}) {
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]);
const initialValues = initial ?? lib.getTemplate(undefined, schema, initialOpts);
const _formStateAtom = useMemo(() => {
return atom<FormState<Data>>({
dirty: false,
submitting: false,
errors: [] as JsonError[],
data: initialValues
});
}, [initialValues]);
const setFormState = useSetAtom(_formStateAtom);
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (initialValues) {
validate();
}
}, [initialValues]);
// @ts-ignore
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
const { data, errors } = validate();
if (onSubmit) {
e.preventDefault();
setFormState((prev) => ({ ...prev, submitting: true }));
try {
if (errors.length === 0) {
await onSubmit(data as Data);
} else {
console.log("invalid", errors);
onInvalidSubmit?.(errors, data);
}
} catch (e) {
console.warn(e);
}
setFormState((prev) => ({ ...prev, submitting: false }));
return false;
} else if (errors.length > 0) {
e.preventDefault();
onInvalidSubmit?.(errors, data);
return false;
}
}
const setValue = useEvent((path: string, value: any) => {
setFormState((state) => {
const prev = state.data;
const changed = immutable.set(prev, path, value);
onChange?.(changed, path, value);
return { ...state, data: changed };
});
check();
});
const deleteValue = useEvent((path: string) => {
setFormState((state) => {
const prev = state.data;
const changed = immutable.del(prev, path);
onChange?.(changed, path, undefined);
return { ...state, data: changed };
});
check();
});
const getCurrentState = useEvent(() => getDefaultStore().get(_formStateAtom));
const check = useEvent(() => {
const state = getCurrentState();
setFormState((prev) => ({ ...prev, dirty: !isEqual(initialValues, state.data) }));
if (validateOn === "change") {
validate();
} else if (state?.errors?.length > 0) {
validate();
}
});
const validate = useEvent((_data?: Partial<Data>) => {
const actual = _data ?? getCurrentState()?.data;
const errors = lib.validate(actual, schema);
setFormState((prev) => ({ ...prev, errors }));
return { data: actual, errors };
});
const context = useMemo(
() => ({
_formStateAtom,
setValue,
deleteValue,
schema,
lib,
options,
root: "",
path: ""
}),
[schema, initialValues]
) as any;
return (
<form {...props} ref={formRef} onSubmit={handleSubmit}>
<FormContext.Provider value={context}>
{children ? children : <Field name="" />}
{options?.debug && <FormDebug />}
</FormContext.Provider>
{hiddenSubmit && (
<button style={{ visibility: "hidden" }} type="submit">
Submit
</button>
)}
</form>
);
}
export function useFormContext() {
return useContext(FormContext);
}
export function FormContextOverride({
children,
overrideData,
prefix,
...overrides
}: Partial<FormContext<any>> & { children: ReactNode; prefix?: string; overrideData?: boolean }) {
const ctx = useFormContext();
const additional: Partial<FormContext<any>> = {};
// this makes a local schema down the three
// especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern)
if (prefix) {
additional.root = prefix;
additional.setValue = (pointer: string, value: any) => {
ctx.setValue(prefixPointer(pointer, prefix), value);
};
additional.deleteValue = (pointer: string) => {
ctx.deleteValue(prefixPointer(pointer, prefix));
};
}
const context = {
...ctx,
...overrides,
...additional
};
return <FormContext.Provider value={context}>{children}</FormContext.Provider>;
}
export function useFormValue(name: string, opts?: { strict?: boolean }) {
const { _formStateAtom, root } = useFormContext();
if ((typeof name !== "string" || name.length === 0) && opts?.strict === true)
return { value: undefined, errors: [] };
const selected = selectAtom(
_formStateAtom,
useCallback(
(state) => {
const prefixedName = prefixPath(name, root);
const pointer = pathToPointer(prefixedName);
return {
value: getPath(state.data, prefixedName),
errors: state.errors.filter((error) => error.data.pointer.startsWith(pointer))
};
},
[name]
),
isEqual
);
return useAtom(selected)[0];
}
export function useFormError(name: string, opt?: { strict?: boolean; debug?: boolean }) {
const { _formStateAtom, root } = useFormContext();
const selected = selectAtom(
_formStateAtom,
useCallback(
(state) => {
const prefixedName = prefixPath(name, root);
const pointer = pathToPointer(prefixedName);
return state.errors.filter((error) => {
return opt?.strict
? error.data.pointer === pointer
: error.data.pointer.startsWith(pointer);
});
},
[name]
),
isEqual
);
return useAtom(selected)[0];
}
export function useFormStateSelector<Data = any, Reduced = Data>(
selector: (state: FormState<Data>) => Reduced
): Reduced {
const { _formStateAtom } = useFormContext();
const selected = selectAtom(_formStateAtom, useCallback(selector, []), isEqual);
return useAtom(selected)[0];
}
type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path,
_schema?: LibJsonSchema,
deriveFn?: SelectorFn<
FormContext<Data> & {
pointer: string;
required: boolean;
value: any;
path: string;
},
Reduced
>
): FormContext<Data> & {
value: Reduced;
pointer: string;
required: boolean;
path: string;
} {
const { _formStateAtom, root, lib, ...ctx } = useFormContext();
const schema = _schema ?? ctx.schema;
const selected = selectAtom(
_formStateAtom,
useCallback(
(state) => {
const pointer = pathToPointer(path);
const prefixedName = prefixPath(path, root);
const prefixedPointer = pathToPointer(prefixedName);
const value = getPath(state.data, prefixedName);
/*const errors = state.errors.filter((error) =>
error.data.pointer.startsWith(prefixedPointer)
);*/
const fieldSchema =
pointer === "#/"
? (schema as LibJsonSchema)
: lib.getSchema({ pointer, data: value, schema });
const required = isRequired(lib, prefixedPointer, schema, state.data);
const context = {
...ctx,
path: prefixedName,
root,
schema: fieldSchema as LibJsonSchema,
pointer,
required
};
const derived = deriveFn?.({ ...context, _formStateAtom, lib, value });
return {
...context,
value: derived
};
},
[path, schema ?? {}, root]
),
isEqual
);
return {
...useAtomValue(selected),
_formStateAtom,
lib
} as any;
}
export function Subscribe<Data = any, Refined = Data>({
children,
selector
}: {
children: (state: Refined) => ReactNode;
selector?: SelectorFn<FormState<Data>, Refined>;
}) {
return children(useFormStateSelector(selector ?? ((state) => state as unknown as Refined)));
}
export function FormDebug({ force = false }: { force?: boolean }) {
const { options } = useFormContext();
if (options?.debug !== true && force !== true) return null;
const ctx = useFormStateSelector((s) => s);
return <JsonViewer json={ctx} expand={99} />;
}

View File

@@ -0,0 +1,47 @@
import type { JSONSchema } from "json-schema-to-ts";
import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
import { AnyOfField } from "./AnyOfField";
import { Field } from "./Field";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext } from "./Form";
export type ObjectFieldProps = {
path?: string;
schema?: Exclude<JSONSchema, boolean>;
label?: string | false;
wrapperProps?: Partial<FieldwrapperProps>;
};
export const ObjectField = ({
path = "",
schema: _schema,
label: _label,
wrapperProps = {}
}: ObjectFieldProps) => {
const ctx = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
const properties = schema.properties ?? {};
return (
<FieldWrapper
name={path}
schema={{ ...schema, description: undefined }}
wrapper="fieldset"
errorPlacement="top"
{...wrapperProps}
>
{Object.keys(properties).map((prop) => {
const schema = properties[prop];
const name = [path, prop].filter(Boolean).join(".");
if (typeof schema === "undefined" || typeof schema === "boolean") return;
if (schema.anyOf || schema.oneOf) {
return <AnyOfField key={name} path={name} />;
}
return <Field key={name} name={name} />;
})}
</FieldWrapper>
);
};

View File

@@ -0,0 +1,6 @@
export * from "./Field";
export * from "./Form";
export * from "./ObjectField";
export * from "./ArrayField";
export * from "./AnyOfField";
export * from "./FieldWrapper";

View File

@@ -0,0 +1,140 @@
import { autoFormatString, omitKeys } from "core/utils";
import { type Draft, Draft2019, type JsonSchema } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts";
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
export { isEqual, getPath } from "core/utils/objects";
//export { isEqual } from "lodash-es";
export function coerce(value: any, schema: JsonSchema, opts?: { required?: boolean }) {
if (!value && typeof opts?.required === "boolean" && !opts.required) {
return undefined;
}
switch (schema.type) {
case "string":
return String(value);
case "integer":
case "number":
return Number(value);
case "boolean":
return ["true", "1", 1, "on", true].includes(value);
case "null":
return null;
}
return value;
}
const PathFilter = (value: any) => typeof value !== "undefined" && value !== null && value !== "";
export function pathToPointer(path: string) {
const p = path.includes(".") ? path.split(".") : [path];
return (
"#" +
p
.filter(PathFilter)
.map((part) => "/" + part)
.join("")
);
}
export function prefixPointer(pointer: string, prefix: string) {
const p = pointer.replace("#", "").split("/");
return "#" + p.map((part, i) => (i === 1 ? prefix : part)).join("/");
}
export function prefixPath(path: string = "", prefix: string | number = "") {
const p = path.includes(".") ? path.split(".") : [path];
return [prefix, ...p].filter(PathFilter).join(".");
}
export function suffixPath(path: string = "", suffix: string | number = "") {
const p = path.includes(".") ? path.split(".") : [path];
return [...p, suffix].filter(PathFilter).join(".");
}
export function getParentPointer(pointer: string) {
return pointer.substring(0, pointer.lastIndexOf("/"));
}
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
if (pointer === "#/" || !schema) {
return false;
}
const childSchema = lib.getSchema({ pointer, data, schema });
if (typeof childSchema === "object" && "const" in childSchema) {
return true;
}
const parentPointer = getParentPointer(pointer);
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
return !!required;
}
export type IsTypeType =
| JSONSchemaType
| JSONSchemaType[]
| readonly JSONSchemaType[]
| string
| undefined;
export function isType(type: IsTypeType, compare: IsTypeType) {
if (!type || !compare) return false;
const _type = Array.isArray(type) ? type : [type];
const _compare = Array.isArray(compare) ? compare : [compare];
return _compare.some((t) => _type.includes(t));
}
export function getLabel(name: string, schema: JsonSchema) {
if (typeof schema === "object" && "title" in schema) return schema.title;
if (!name) return "";
const label = name.includes(".") ? (name.split(".").pop() ?? "") : name;
return autoFormatString(label);
}
export function getMultiSchema(schema: JsonSchema): JsonSchema[] | undefined {
if (!schema || typeof schema !== "object") return;
return (schema.anyOf ?? schema.oneOf) as any;
}
export function getMultiSchemaMatched(
schema: JsonSchema,
data: any
): [number, JsonSchema[], JsonSchema | undefined] {
const multiSchema = getMultiSchema(schema);
//console.log("getMultiSchemaMatched", schema, data, multiSchema);
if (!multiSchema) return [-1, [], undefined];
const index = multiSchema.findIndex((subschema) => {
const lib = new Draft2019(subschema as any);
return lib.validate(data, subschema).length === 0;
});
if (index === -1) return [-1, multiSchema, undefined];
return [index, multiSchema, multiSchema[index]];
}
export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: string[], _data?: any) {
if (typeof _schema !== "object" || !("properties" in _schema) || keys.length === 0)
return [_schema, _data];
const schema = JSON.parse(JSON.stringify(_schema));
const data = _data ? JSON.parse(JSON.stringify(_data)) : undefined;
const updated = {
...schema,
properties: omitKeys(schema.properties, keys)
};
if (updated.required) {
updated.required = updated.required.filter((key) => !keys.includes(key as any));
}
const reducedConfig = omitKeys(data, keys) as any;
return [updated, reducedConfig];
}
export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
}

View File

@@ -0,0 +1,215 @@
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type FormEvent,
useEffect,
useRef,
useState
} from "react";
import { useEvent } from "ui/hooks/use-event";
import {
type CleanOptions,
type InputElement,
cleanObject,
coerce,
getFormTarget,
getTargetsByName,
setPath
} from "./utils";
export type NativeFormProps = {
hiddenSubmit?: boolean;
validateOn?: "change" | "submit";
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
reportValidity?: boolean;
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
onSubmitInvalid?: (
errors: InputError[],
ctx: { event: FormEvent<HTMLFormElement> }
) => Promise<void> | void;
onError?: (errors: InputError[]) => void;
onChange?: (
data: any,
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }
) => Promise<void> | void;
clean?: CleanOptions | true;
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
export type InputError = {
name: string;
message: string;
};
export function NativeForm({
children,
validateOn = "submit",
hiddenSubmit = false,
errorFieldSelector,
reportValidity,
onSubmit,
onSubmitInvalid,
onError,
clean,
...props
}: NativeFormProps) {
const formRef = useRef<HTMLFormElement>(null);
const [errors, setErrors] = useState<InputError[]>([]);
useEffect(() => {
if (!formRef.current || props.noValidate) return;
validate();
}, []);
useEffect(() => {
if (!formRef.current || props.noValidate) return;
// find submit buttons and disable them if there are errors
const invalid = errors.length > 0;
formRef.current.querySelectorAll("[type=submit]").forEach((submit) => {
if (!submit || !("type" in submit) || submit.type !== "submit") return;
// @ts-ignore
submit.disabled = invalid;
});
onError?.(errors);
}, [errors]);
const validateElement = useEvent((el: InputElement | null, opts?: { report?: boolean }) => {
if (props.noValidate || !el || !("name" in el)) return;
const errorElement = formRef.current?.querySelector(
errorFieldSelector?.(el.name) ?? `[data-role="input-error"][data-name="${el.name}"]`
);
if (!el.checkValidity()) {
const error = {
name: el.name,
message: el.validationMessage
};
setErrors((prev) => [...prev.filter((e) => e.name !== el.name), error]);
if (opts?.report) {
if (errorElement) {
errorElement.textContent = error.message;
} else if (reportValidity) {
el.reportValidity();
}
}
return error;
} else {
setErrors((prev) => prev.filter((e) => e.name !== el.name));
if (errorElement) {
errorElement.textContent = "";
}
}
return;
});
const validate = useEvent((opts?: { report?: boolean }) => {
if (!formRef.current || props.noValidate) return [];
const errors: InputError[] = [];
formRef.current.querySelectorAll("input, select, textarea").forEach((e) => {
const el = e as InputElement | null;
const error = validateElement(el, opts);
if (error) {
errors.push(error);
}
});
return errors;
});
const getFormValues = useEvent(() => {
if (!formRef.current) return {};
const formData = new FormData(formRef.current);
const obj: any = {};
formData.forEach((value, key) => {
const targets = getTargetsByName(formRef.current!, key);
if (targets.length === 0) {
console.warn(`No target found for key: ${key}`);
return;
}
const count = targets.length;
const multiple = count > 1;
targets.forEach((target, index) => {
let _key = key;
if (multiple) {
_key = `${key}[${index}]`;
}
setPath(obj, _key, coerce(target, target.value));
});
});
if (typeof clean === "undefined") return obj;
return cleanObject(obj, clean === true ? undefined : clean);
});
const handleChange = useEvent(async (e: ChangeEvent<HTMLFormElement>) => {
const form = formRef.current;
if (!form) return;
const target = getFormTarget(e);
if (!target) return;
if (validateOn === "change") {
validateElement(target, { report: true });
}
if (props.onChange) {
await props.onChange(getFormValues(), {
event: e,
key: target.name,
value: target.value,
errors
});
}
});
const handleSubmit = useEvent(async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = formRef.current;
if (!form) return;
const errors = validate({ report: true });
if (errors.length > 0) {
onSubmitInvalid?.(errors, { event: e });
return;
}
if (onSubmit) {
await onSubmit(getFormValues(), { event: e });
} else {
form.submit();
}
});
const handleKeyDown = useEvent((e: KeyboardEvent) => {
if (!formRef.current) return;
// if is enter key, submit is disabled, report errors
if (e.keyCode === 13) {
const invalid = errors.length > 0;
if (invalid && !props.noValidate && reportValidity) {
formRef.current.reportValidity();
}
}
});
return (
<form
{...props}
onChange={handleChange}
onSubmit={handleSubmit}
ref={formRef}
onKeyDown={handleKeyDown as any}
>
{children}
{hiddenSubmit && <input type="submit" style={{ visibility: "hidden" }} />}
</form>
);
}

View File

@@ -0,0 +1,137 @@
import type { FormEvent } from "react";
export type InputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
export function ignoreTarget(target: InputElement | Element | null, form?: HTMLFormElement) {
const tagName = target?.tagName.toLowerCase() ?? "";
const tagNames = ["input", "select", "textarea"];
return (
!target ||
!form?.contains(target) ||
!tagNames.includes(tagName) ||
!("name" in target) ||
target.hasAttribute("data-ignore") ||
target.closest("[data-ignore]")
);
}
export function getFormTarget(e: FormEvent<HTMLFormElement>): InputElement | null {
const form = e.currentTarget;
const target = e.target as InputElement | null;
return ignoreTarget(target, form) ? null : target;
}
export function getTargetsByName(form: HTMLFormElement, name: string): InputElement[] {
const query = form.querySelectorAll(`[name="${name}"]`);
return Array.from(query).filter((e) => ignoreTarget(e)) as InputElement[];
}
export function coerce(target: InputElement | null, value?: any) {
if (!target) return value;
const required = target.required;
if (!value && !required) return undefined;
if (target.type === "number") {
const num = Number(value);
if (Number.isNaN(num) && !required) return undefined;
const min = "min" in target && target.min.length > 0 ? Number(target.min) : undefined;
const max = "max" in target && target.max.length > 0 ? Number(target.max) : undefined;
const step = "step" in target && target.step.length > 0 ? Number(target.step) : undefined;
if (min && num < min) return min;
if (max && num > max) return max;
if (step && step !== 1) return Math.round(num / step) * step;
return num;
} else if (target.type === "text") {
const maxLength =
"maxLength" in target && target.maxLength > -1 ? Number(target.maxLength) : undefined;
const pattern = "pattern" in target ? new RegExp(target.pattern) : undefined;
if (maxLength && value.length > maxLength) return value.slice(0, maxLength);
if (pattern && !pattern.test(value)) return "";
return value;
} else if (target.type === "checkbox") {
if ("checked" in target) return !!target.checked;
return ["on", "1", "true", 1, true].includes(value);
} else {
return value;
}
}
export type CleanOptions = {
empty?: any[];
emptyInArray?: any[];
keepEmptyArray?: boolean;
};
export function cleanObject<Obj extends { [key: string]: any }>(
obj: Obj,
_opts?: CleanOptions
): Obj {
if (!obj) return obj;
const _empty = [null, undefined, ""];
const opts = {
empty: _opts?.empty ?? _empty,
emptyInArray: _opts?.emptyInArray ?? _empty,
keepEmptyArray: _opts?.keepEmptyArray ?? false
};
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
const nested = value.map((o) => cleanObject(o, opts));
if (nested.length > 0 || opts?.keepEmptyArray) {
acc[key] = nested;
}
} else if (value && typeof value === "object" && !Array.isArray(value)) {
const nested = cleanObject(value, opts);
if (Object.keys(nested).length > 0) {
acc[key] = nested;
}
} else if (Array.isArray(value)) {
const nested = value.filter((v) => !opts.emptyInArray.includes(v));
if (nested.length > 0 || opts?.keepEmptyArray) {
acc[key] = nested;
}
} else if (!opts.empty.includes(value)) {
acc[key] = value;
}
return acc;
}, {} as any);
}
export function setPath(object, _path, value) {
let path = _path;
// Optional string-path support.
// You can remove this `if` block if you don't need it.
if (typeof path === "string") {
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
path = path
.split(/[.\[\]]+/)
.filter((x) => x)
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
}
if (path.length === 0) {
throw new Error("The path must have at least one entry in it");
}
const [head, ...tail] = path;
if (tail.length === 0) {
object[head] = value;
return object;
}
if (!(head in object)) {
object[head] = typeof tail[0] === "number" ? [] : {};
}
setPath(object[head], tail, value);
return object;
}

View File

@@ -1,7 +1,7 @@
import { Modal, type ModalProps, Popover } from "@mantine/core"; import { Modal, type ModalProps, Popover } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconBug } from "@tabler/icons-react"; import { IconBug } from "@tabler/icons-react";
import { ScrollArea } from "radix-ui";
import { Fragment, forwardRef, useImperativeHandle } from "react"; import { Fragment, forwardRef, useImperativeHandle } from "react";
import { TbX } from "react-icons/tb"; import { TbX } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";

View File

@@ -1,7 +1,7 @@
import { useClickOutside } from "@mantine/hooks"; import { useClickOutside } from "@mantine/hooks";
import { type ReactElement, cloneElement, useState } from "react"; import { type ComponentPropsWithoutRef, type ReactElement, cloneElement, useState } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
export type PopoverProps = { export type PopoverProps = {
className?: string; className?: string;
@@ -10,6 +10,7 @@ export type PopoverProps = {
backdrop?: boolean; backdrop?: boolean;
target: (props: { toggle: () => void }) => ReactElement; target: (props: { toggle: () => void }) => ReactElement;
children: ReactElement<{ onClick: () => void }>; children: ReactElement<{ onClick: () => void }>;
overlayProps?: ComponentPropsWithoutRef<"div">;
}; };
export function Popover({ export function Popover({
@@ -18,20 +19,21 @@ export function Popover({
defaultOpen = false, defaultOpen = false,
backdrop = false, backdrop = false,
position = "bottom-start", position = "bottom-start",
className, overlayProps,
className
}: PopoverProps) { }: PopoverProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const clickoutsideRef = useClickOutside(() => setOpen(false)); const clickoutsideRef = useClickOutside(() => setOpen(false));
const toggle = useEvent((delay: number = 50) => const toggle = useEvent((delay: number = 50) =>
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0), setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
); );
const pos = { const pos = {
"bottom-start": "mt-1 top-[100%]", "bottom-start": "mt-1 top-[100%]",
"bottom-end": "right-0 top-[100%] mt-1", "bottom-end": "right-0 top-[100%] mt-1",
"top-start": "bottom-[100%] mb-1", "top-start": "bottom-[100%] mb-1",
"top-end": "bottom-[100%] right-0 mb-1", "top-end": "bottom-[100%] right-0 mb-1"
}[position]; }[position];
return ( return (
@@ -43,9 +45,11 @@ export function Popover({
{cloneElement(children as any, { onClick: toggle })} {cloneElement(children as any, { onClick: toggle })}
{open && ( {open && (
<div <div
{...overlayProps}
className={twMerge( className={twMerge(
"animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full max-w-20 backdrop-blur-sm", "animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg backdrop-blur-sm min-w-0 max-w-20",
pos, pos,
overlayProps?.className
)} )}
> >
{target({ toggle })} {target({ toggle })}

View File

@@ -1,22 +0,0 @@
import * as ReactScrollArea from "@radix-ui/react-scroll-area";
export const ScrollArea = ({ children, className }: any) => (
<ReactScrollArea.Root className={`${className} `}>
<ReactScrollArea.Viewport className="w-full h-full ">
{children}
</ReactScrollArea.Viewport>
<ReactScrollArea.Scrollbar
className="ScrollAreaScrollbar"
orientation="vertical"
>
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
</ReactScrollArea.Scrollbar>
<ReactScrollArea.Scrollbar
className="ScrollAreaScrollbar"
orientation="horizontal"
>
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
</ReactScrollArea.Scrollbar>
<ReactScrollArea.Corner className="ScrollAreaCorner" />
</ReactScrollArea.Root>
);

View File

@@ -1,86 +0,0 @@
import {
type ComponentProps,
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type ElementRef,
type ElementType,
type ForwardedRef,
type PropsWithChildren,
type ReactElement,
forwardRef
} from "react";
export function extend<ComponentType extends ElementType, AdditionalProps = {}>(
Component: ComponentType,
applyAdditionalProps?: (
props: PropsWithChildren<ComponentPropsWithoutRef<ComponentType> & AdditionalProps> & {
className?: string;
}
) => ComponentProps<ComponentType>
) {
return forwardRef<
ElementRef<ComponentType>,
ComponentPropsWithoutRef<ComponentType> & AdditionalProps
>((props, ref) => {
// Initialize newProps with a default empty object or the result of applyAdditionalProps
let newProps: ComponentProps<ComponentType> & AdditionalProps = applyAdditionalProps
? applyAdditionalProps(props as any)
: (props as any);
// Append className if it exists in both props and newProps
if (props.className && newProps.className) {
newProps = {
...newProps,
className: `${props.className} ${newProps.className}`
};
}
// @ts-expect-error haven't figured out the correct typing
return <Component {...newProps} ref={ref} />;
});
}
type RenderFunction<ComponentType extends React.ElementType, AdditionalProps = {}> = (
props: PropsWithChildren<ComponentPropsWithRef<ComponentType> & AdditionalProps> & {
className?: string;
},
ref: ForwardedRef<ElementRef<ComponentType>>
) => ReactElement;
export function extendComponent<ComponentType extends React.ElementType, AdditionalProps = {}>(
renderFunction: RenderFunction<ComponentType, AdditionalProps>
) {
// The extended component using forwardRef to forward the ref to the custom component
const ExtendedComponent = forwardRef<
ElementRef<ComponentType>,
ComponentPropsWithRef<ComponentType> & AdditionalProps
>((props, ref) => {
return renderFunction(props as any, ref);
});
return ExtendedComponent;
}
/*
export const Content = forwardRef<
ElementRef<typeof DropdownMenu.Content>,
ComponentPropsWithoutRef<typeof DropdownMenu.Content>
>(({ className, ...props }, forwardedRef) => (
<DropdownMenu.Content
className={`flex flex-col ${className}`}
{...props}
ref={forwardedRef}
/>
));
export const Item = forwardRef<
ElementRef<typeof DropdownMenu.Item>,
ComponentPropsWithoutRef<typeof DropdownMenu.Item>
>(({ className, ...props }, forwardedRef) => (
<DropdownMenu.Item
className={`flex flex-row flex-nowrap ${className}`}
{...props}
ref={forwardedRef}
/>
));
*/

View File

@@ -1,14 +1,23 @@
import type { ValueError } from "@sinclair/typebox/value";
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema"; import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx"; import clsx from "clsx";
import { type TSchema, Type, Value } from "core/utils"; import { Type } from "core/utils";
import { Form, type Validator } from "json-schema-form-react"; import { Form } from "json-schema-form-react";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react"; import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy/components"; import { Group, Input, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink"; import { SocialLink } from "./SocialLink";
import type { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils";
import type { Validator } from "json-schema-form-react";
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
}
}
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & { export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string; className?: string;
formData?: any; formData?: any;
@@ -18,14 +27,7 @@ export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" |
buttonLabel?: string; buttonLabel?: string;
}; };
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
}
}
const validator = new TypeboxValidator(); const validator = new TypeboxValidator();
const schema = Type.Object({ const schema = Type.Object({
email: Type.String({ email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$" pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
@@ -82,7 +84,7 @@ export function AuthForm({
<Form <Form
method={method} method={method}
action={password.action} action={password.action}
{...props} {...(props as any)}
schema={schema} schema={schema}
validator={validator} validator={validator}
validationMode="change" validationMode="change"

View File

@@ -22,9 +22,17 @@ export function SocialLink({
basepath = "/api/auth", basepath = "/api/auth",
children children
}: SocialLinkProps) { }: SocialLinkProps) {
const url = [basepath, provider, action].join("/");
return ( return (
<form method={method} action={[basepath, name, action].join("/")} className="w-full"> <form method={method} action={url} className="w-full">
<Button type="submit" size="large" variant="outline" className="justify-center w-full"> <Button
type="submit"
size="large"
variant="outline"
className="justify-center w-full"
IconLeft={icon}
>
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)} Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
</Button> </Button>
{children} {children}

View File

@@ -1,2 +1,3 @@
export * from "./auth"; export * from "./auth";
export * from "./media"; export * from "./media";
export * from "../components/form/native-form/NativeForm";

View File

@@ -1 +1,3 @@
export { default as Admin, type BkndAdminProps } from "./Admin"; export { default as Admin, type BkndAdminProps } from "./Admin";
export * from "./components/form/json-schema-form";
export { JsonViewer } from "./components/code/JsonViewer";

View File

@@ -1,13 +1,19 @@
import { useClickOutside, useHotkeys } from "@mantine/hooks"; import { useClickOutside, useHotkeys } from "@mantine/hooks";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { throttle } from "lodash-es"; import { throttle } from "lodash-es";
import { type ComponentProps, useEffect, useRef, useState } from "react"; import { ScrollArea } from "radix-ui";
import {
type ComponentProps,
type ComponentPropsWithoutRef,
useEffect,
useRef,
useState
} from "react";
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useEvent } from "../../hooks/use-event";
export function Root({ children }) { export function Root({ children }) {
return ( return (
@@ -68,8 +74,15 @@ export function Content({ children, center }: { children: React.ReactNode; cente
} }
export function Main({ children }) { export function Main({ children }) {
const { sidebar } = useAppShell();
return ( return (
<div data-shell="main" className="flex flex-col flex-grow w-1"> <div
data-shell="main"
className={twMerge(
"flex flex-col flex-grow w-1 flex-shrink-1",
sidebar.open && "max-w-[calc(100%-350px)]"
)}
>
{children} {children}
</div> </div>
); );
@@ -292,7 +305,7 @@ export function Scrollable({
return ( return (
<ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}> <ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}>
<ScrollArea.Viewport className="w-full h-full ">{children}</ScrollArea.Viewport> <ScrollArea.Viewport className="w-full h-full">{children}</ScrollArea.Viewport>
<ScrollArea.Scrollbar <ScrollArea.Scrollbar
forceMount forceMount
className="flex select-none touch-none bg-transparent w-0.5" className="flex select-none touch-none bg-transparent w-0.5"
@@ -357,4 +370,8 @@ export const SectionHeaderAccordionItem = ({
</div> </div>
); );
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
<hr {...props} className={twMerge("border-muted my-3", className)} />
);
export { Header } from "./Header"; export { Header } from "./Header";

View File

@@ -13,6 +13,7 @@ import {
import { useAuth, useBkndWindowContext } from "ui/client"; import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
@@ -72,18 +73,15 @@ export function HeaderNavigation() {
<> <>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center"> <nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{items.map((item) => ( {items.map((item) => (
<Tooltip <NavLink
key={item.label} key={item.href}
label={item.tooltip} as={Link}
disabled={typeof item.tooltip === "undefined"} href={item.href}
position="bottom" Icon={item.Icon}
disabled={item.disabled}
> >
<div> {item.label}
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}> </NavLink>
{item.label}
</NavLink>
</div>
</Tooltip>
))} ))}
</nav> </nav>
<nav className="flex md:hidden flex-row items-center"> <nav className="flex md:hidden flex-row items-center">
@@ -114,9 +112,9 @@ function SidebarToggler() {
} }
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
//const logoReturnPath = "";
const { app } = useBknd(); const { app } = useBknd();
const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig(); const { theme } = useTheme();
const { logo_return_path = "/" } = app.getAdminConfig();
return ( return (
<header <header
@@ -128,7 +126,7 @@ export function Header({ hasSidebar = true }) {
native={logo_return_path !== "/"} native={logo_return_path !== "/"}
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none" className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
> >
<Logo theme={color_scheme} /> <Logo theme={theme} />
</Link> </Link>
<HeaderNavigation /> <HeaderNavigation />
<div className="flex flex-grow" /> <div className="flex flex-grow" />

View File

@@ -2,6 +2,35 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
#bknd-admin.dark,
.dark .bknd-admin {
--color-primary: 250 250 250; /* zinc-50 */
--color-background: 30 31 34;
--color-muted: 47 47 52;
--color-darkest: 255 255 255; /* white */
--color-lightest: 24 24 27; /* black */
}
#bknd-admin,
.bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */
--color-background: 250 250 250; /* zinc-50 */
--color-muted: 228 228 231; /* ? */
--color-darkest: 0 0 0; /* black */
--color-lightest: 255 255 255; /* white */
@mixin light {
--mantine-color-body: rgb(250 250 250);
}
@mixin dark {
--mantine-color-body: rgb(9 9 11);
}
table {
font-size: inherit;
}
}
#bknd-admin { #bknd-admin {
@apply bg-background text-primary overflow-hidden h-dvh w-dvw; @apply bg-background text-primary overflow-hidden h-dvh w-dvw;

View File

@@ -40,7 +40,7 @@ export function DataEntityList({ params }) {
useBrowserTitle(["Data", entity?.label ?? params.entity]); useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const search = useSearch(searchSchema, { const search = useSearch(searchSchema, {
select: entity.getSelect(undefined, "form"), select: entity.getSelect(undefined, "table"),
sort: entity.getDefaultSort() sort: entity.getDefaultSort()
}); });

View File

@@ -1,5 +1,6 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth"; import AuthRoutes from "./auth";
import { AuthLogin } from "./auth/auth.login"; import { AuthLogin } from "./auth/auth.login";
@@ -20,11 +21,11 @@ const TestRoutes = lazy(() => import("./test"));
export function Routes() { export function Routes() {
const { app } = useBknd(); const { app } = useBknd();
const { color_scheme: theme } = app.getAdminConfig(); const { theme } = useTheme();
const { basepath } = app.getAdminConfig(); const { basepath } = app.getAdminConfig();
return ( return (
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}> <div id="bknd-admin" className={theme + " antialiased"}>
<Router base={basepath}> <Router base={basepath}>
<Switch> <Switch>
<Route path="/auth/login" component={AuthLogin} /> <Route path="/auth/login" component={AuthLogin} />

View File

@@ -1,33 +1,17 @@
import { IconPhoto } from "@tabler/icons-react"; import { IconAlertHexagon } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb"; import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements"; import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
export function MediaRoot({ children }) { export function MediaRoot({ children }) {
const { app, config } = useBknd(); const { app, config } = useBknd();
const [, navigate] = useLocation(); const mediaDisabled = !config.media.enabled;
useBrowserTitle(["Media"]); useBrowserTitle(["Media"]);
if (!config.media.enabled) {
return (
<Empty
Icon={IconPhoto}
title="Media not enabled"
description="Please enable media in the settings to continue."
primary={{
children: "Manage Settings",
onClick: () => navigate(app.getSettingsPath(["media"]))
}}
/>
);
}
return ( return (
<> <>
<AppShell.Sidebar> <AppShell.Sidebar>
@@ -42,31 +26,22 @@ export function MediaRoot({ children }) {
</AppShell.SectionHeader> </AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}> <AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3"> <div className="flex flex-col flex-grow p-3 gap-3">
{/*<div>
<SearchInput placeholder="Search buckets" />
</div>*/}
<nav className="flex flex-col flex-1 gap-1"> <nav className="flex flex-col flex-1 gap-1">
<AppShell.SidebarLink as={Link} href="/media" className="active"> <AppShell.SidebarLink
Main Bucket as={Link}
href={"/"}
className="flex flex-row justify-between"
>
Main Bucket {mediaDisabled && <IconAlertHexagon className="size-5" />}
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={"/settings"}>
Settings
</AppShell.SidebarLink> </AppShell.SidebarLink>
</nav> </nav>
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
</AppShell.Sidebar> </AppShell.Sidebar>
<main className="flex flex-col flex-grow">{children}</main> <AppShell.Main>{children}</AppShell.Main>
</> </>
); );
} }
// @todo: add infinite load
export function MediaEmpty() {
useBrowserTitle(["Media"]);
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Media.Dropzone />
</div>
</AppShell.Scrollable>
);
}

View File

@@ -1,10 +1,13 @@
import { Route } from "wouter"; import { Route } from "wouter";
import { MediaEmpty, MediaRoot } from "./_media.root"; import { MediaRoot } from "./_media.root";
import { MediaIndex } from "./media.index";
import { MediaSettings } from "./media.settings";
export default function MediaRoutes() { export default function MediaRoutes() {
return ( return (
<MediaRoot> <MediaRoot>
<Route path="/" component={MediaEmpty} /> <Route path="/" component={MediaIndex} />
<Route path="/settings" component={MediaSettings} />
</MediaRoot> </MediaRoot>
); );
} }

View File

@@ -0,0 +1,35 @@
import { IconPhoto } from "@tabler/icons-react";
import { useBknd } from "ui/client/BkndProvider";
import { Empty } from "ui/components/display/Empty";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
export function MediaIndex() {
const { app, config } = useBknd();
const [, navigate] = useLocation();
useBrowserTitle(["Media"]);
if (!config.media.enabled) {
return (
<Empty
Icon={IconPhoto}
title="Media not enabled"
description="Please enable media in the settings to continue."
primary={{
children: "Manage Settings",
onClick: () => navigate("/settings")
}}
/>
);
}
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Media.Dropzone />
</div>
</AppShell.Scrollable>
);
}

View File

@@ -0,0 +1,179 @@
import { IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react";
import { isDebug } from "core";
import { autoFormatString } from "core/utils";
import { twMerge } from "tailwind-merge";
import { useBknd } from "ui/client/BkndProvider";
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import { Message } from "ui/components/display/Message";
import * as Formy from "ui/components/form/Formy";
import {
AnyOf,
Field,
Form,
FormContextOverride,
FormDebug,
ObjectField,
Subscribe,
useFormError
} from "ui/components/form/json-schema-form";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
export function MediaSettings(props) {
useBrowserTitle(["Media", "Settings"]);
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Media Settings" />;
}
return <MediaSettingsInternal {...props} />;
}
const formConfig = {
ignoreKeys: ["entity_name", "basepath"],
options: { debug: isDebug(), keepEmpty: true }
};
function MediaSettingsInternal() {
const { config, schema: _schema, actions } = useBkndMedia();
const schema = JSON.parse(JSON.stringify(_schema));
schema.if = { properties: { enabled: { const: true } } };
// biome-ignore lint/suspicious/noThenProperty: <explanation>
schema.then = { required: ["adapter"] };
async function onSubmit(data: any) {
console.log("submit", data);
await actions.config.patch(data);
}
return (
<>
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
errors: state.errors.length > 0,
submitting: state.submitting
})}
>
{({ dirty, errors, submitting }) => (
<AppShell.SectionHeader
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
}
>
Settings
</AppShell.SectionHeader>
)}
</Subscribe>
<AppShell.Scrollable>
<RootFormError />
<div className="flex flex-col gap-3 p-3">
<Field name="enabled" />
<div className="flex flex-col gap-3 relative">
<Overlay />
<Field name="storage.body_max_size" label="Storage Body Max Size" />
</div>
</div>
<AppShell.Separator />
<div className="flex flex-col gap-3 p-3">
<Overlay />
<AnyOf.Root path="adapter">
<Adapters />
</AnyOf.Root>
</div>
<FormDebug />
</AppShell.Scrollable>
</Form>
</>
);
}
const RootFormError = () => {
const errors = useFormError("", { strict: true });
if (errors.length === 0) return null;
return (
<Alert.Exception>
{errors.map((error, i) => (
<div key={i}>{error.message}</div>
))}
</Alert.Exception>
);
};
const Icons = [IconBrandAws, IconCloud, IconServer];
const AdapterIcon = ({ index }: { index: number }) => {
const Icon = Icons[index];
if (!Icon) return null;
return <Icon />;
};
function Adapters() {
const ctx = AnyOf.useContext();
return (
<Formy.Group>
<Formy.Label className="flex flex-row items-center gap-1">
<span className="font-bold">Media Adapter:</span>
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
</Formy.Label>
<div className="flex flex-row gap-1 mb-2">
{ctx.schemas?.map((schema: any, i) => (
<Button
key={i}
onClick={() => ctx.select(i)}
variant={ctx.selected === i ? "primary" : "outline"}
className={twMerge(
"flex flex-row items-center justify-center gap-3 border",
ctx.selected === i && "border-primary"
)}
>
<div>
<AdapterIcon index={i} />
</div>
<div className="flex flex-col items-start justify-center">
<span>{autoFormatString(schema.title)}</span>
{schema.description && (
<span className="text-xs opacity-70 text-left">{schema.description}</span>
)}
</div>
</Button>
))}
</div>
{ctx.selected !== null && (
<Formy.Group as="fieldset" error={ctx.errors.length > 0}>
<Formy.Label as="legend" className="font-mono px-2">
{autoFormatString(ctx.selectedSchema!.title!)}
</Formy.Label>
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
<Field name="type" hidden />
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
</FormContextOverride>
</Formy.Group>
)}
</Formy.Group>
);
}
const Overlay = () => (
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
{({ enabled }) =>
!enabled && (
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
)
}
</Subscribe>
);

View File

@@ -1,5 +1,8 @@
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test"; import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test"; import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
import FormyTest from "ui/routes/test/tests/formy-test";
import HtmlFormTest from "ui/routes/test/tests/html-form-test";
import SwaggerTest from "ui/routes/test/tests/swagger-test"; import SwaggerTest from "ui/routes/test/tests/swagger-test";
import SWRAndAPI from "ui/routes/test/tests/swr-and-api"; import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api"; import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
@@ -15,6 +18,7 @@ import DropdownTest from "./tests/dropdown-test";
import DropzoneElementTest from "./tests/dropzone-element-test"; import DropzoneElementTest from "./tests/dropzone-element-test";
import EntityFieldsForm from "./tests/entity-fields-form"; import EntityFieldsForm from "./tests/entity-fields-form";
import FlowsTest from "./tests/flows-test"; import FlowsTest from "./tests/flows-test";
import JsonSchemaForm3 from "./tests/json-schema-form3";
import JsonFormTest from "./tests/jsonform-test"; import JsonFormTest from "./tests/jsonform-test";
import { LiquidJsTest } from "./tests/liquid-js-test"; import { LiquidJsTest } from "./tests/liquid-js-test";
import MantineTest from "./tests/mantine-test"; import MantineTest from "./tests/mantine-test";
@@ -45,7 +49,10 @@ const tests = {
SWRAndAPI, SWRAndAPI,
SwrAndDataApi, SwrAndDataApi,
DropzoneElementTest, DropzoneElementTest,
JsonSchemaFormReactTest JsonSchemaFormReactTest,
JsonSchemaForm3,
FormyTest,
HtmlFormTest
} as const; } as const;
export default function TestRoutes() { export default function TestRoutes() {
@@ -83,7 +90,7 @@ function TestRoot({ children }) {
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
</AppShell.Sidebar> </AppShell.Sidebar>
<main className="flex flex-col flex-grow">{children}</main> <AppShell.Main>{children}</AppShell.Main>
</> </>
); );
} }

View File

@@ -0,0 +1,17 @@
import * as Formy from "ui/components/form/Formy";
export default function FormyTest() {
return (
<div className="flex flex-col gap-3">
formy
<Formy.Group>
<Formy.Label>label</Formy.Label>
<Formy.Switch onCheckedChange={console.log} />
</Formy.Group>
<Formy.Group>
<Formy.Label>label</Formy.Label>
<Formy.Input />
</Formy.Group>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import { Button } from "ui/components/buttons/Button";
import { JsonViewer } from "ui/components/code/JsonViewer";
import * as Formy from "ui/components/form/Formy";
import { NativeForm } from "ui/components/form/native-form/NativeForm";
export default function HtmlFormTest() {
const [data, setData] = useState<any>();
const [errors, setErrors] = useState<any>();
return (
<div className="flex flex-col p-3">
<h1>html</h1>
<NativeForm
className="flex flex-col gap-3"
validateOn="change"
onChange={setData}
onSubmit={(data) => console.log("submit", data)}
onSubmitInvalid={(errors) => console.log("invalid", errors)}
onError={setErrors}
reportValidity
clean
>
<Formy.Input type="text" name="what" minLength={2} maxLength={5} required />
<div data-role="input-error" data-name="what" />
<Formy.Input type="number" name="age" step={5} required />
<Formy.Input type="checkbox" name="verified" />
<Formy.Input type="text" name="tag" minLength={1} required />
<Formy.Input type="number" name="tag" />
<Button type="submit">submit</Button>
</NativeForm>
<JsonViewer json={{ data, errors }} expand={9} />
</div>
);
}

View File

@@ -23,8 +23,8 @@ export default function JsonSchemaFormReactTest() {
<> <>
<Form <Form
schema={schema} schema={schema}
onChange={setData} /*onChange={setData}
onSubmit={setData} onSubmit={setData}*/
validator={validator} validator={validator}
validationMode="change" validationMode="change"
> >

View File

@@ -0,0 +1,371 @@
import type { JSONSchema } from "json-schema-to-ts";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import {
AnyOf,
AnyOfField,
Field,
Form,
FormContextOverride,
FormDebug,
ObjectField,
useFormError
} from "ui/components/form/json-schema-form";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
const schema2 = {
type: "object",
properties: {
name: { type: "string", default: "Peter" },
age: { type: "number" },
gender: {
type: "string",
enum: ["male", "female", "uni"]
},
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"]
} as const satisfies JSONSchema;
export default function JsonSchemaForm3() {
const { schema: _schema, config } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema));
config.media.storage.body_max_size = 1;
schema.media.properties.storage.properties.body_max_size.minimum = 0;
schema.media.if = { properties: { enabled: { const: true } } };
// biome-ignore lint/suspicious/noThenProperty: <explanation>
schema.media.then = { required: ["adapter"] };
//schema.media.properties.adapter.anyOf[2].properties.config.properties.path.minLength = 1;
return (
<Scrollable>
<div className="flex flex-col p-3">
<Form
onChange={(data) => console.log("change", data)}
onSubmit={(data) => console.log("submit", data)}
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", maxLength: 3 },
age: { type: "number" },
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"],
additionalProperties: false
}}
initialValues={{ name: "Peter", age: 20, deep: { nested: "hello" } }}
className="flex flex-col gap-3"
validateOn="change"
options={{ debug: true }}
/>
{/*<Form
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", minLength: 3 },
age: { type: "number" },
deep: {
anyOf: [
{
type: "object",
properties: {
nested: { type: "string" }
}
},
{
type: "object",
properties: {
nested2: { type: "string" }
}
}
]
}
},
required: ["age"]
}}
className="flex flex-col gap-3"
validateOn="change"
>
<Field name="" />
<Subscribe2>
{(state) => (
<pre className="text-wrap whitespace-break-spaces break-all">
{JSON.stringify(state, null, 2)}
</pre>
)}
</Subscribe2>
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
name: { type: "string", default: "Peter", maxLength: 3 },
age: { type: "number" },
gender: {
type: "string",
enum: ["male", "female", "uni"]
},
deep: {
type: "object",
properties: {
nested: { type: "string" }
}
}
},
required: ["age"]
}}
className="flex flex-col gap-3"
validateOn="change"
>
<div>random thing</div>
<Field name="name" />
<Field name="age" />
<FormDebug />
<FormDebug2 name="name" />
<hr />
<Subscribe2
selector={(state) => ({ dirty: state.dirty, submitting: state.submitting })}
>
{(state) => (
<pre className="text-wrap whitespace-break-spaces break-all">
{JSON.stringify(state)}
</pre>
)}
</Subscribe2>
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
bla: {
anyOf: [
{
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" }
}
},
{
type: "object",
properties: {
start: { type: "string", enum: ["a", "b", "c"] },
end: { type: "number" }
}
}
]
}
}
}}
>
<AutoForm />
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
title: {
type: "string"
},
tags: {
type: "array",
items: {
type: "string"
}
}
}
}}
initialValues={{ tags: ["a", "b", "c"] }}
>
<AutoForm />
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
title: {
type: "string"
},
tags: {
type: "array",
items: {
type: "number"
}
},
method: {
type: "array",
uniqueItems: true,
items: {
type: "string",
enum: ["GET", "POST", "PUT", "DELETE"]
}
}
}
}}
initialValues={{ tags: [0, 1], method: ["GET"] }}
options={{ debug: true }}
>
<Field name="" />
<FormDebug />
</Form>*/}
{/*<Form
schema={{
type: "object",
properties: {
title: {
type: "string"
},
tags: {
type: "array",
items: {
anyOf: [
{ type: "string", title: "String" },
{ type: "number", title: "Number" }
]
}
}
}
}}
initialValues={{ tags: [0, 1] }}
>
<Field name="" />
<FormDebug force />
</Form>*/}
{/*<CustomMediaForm />*/}
{/*<Form
schema={schema.media}
initialValues={config.media as any}
onSubmit={console.log}
validateOn="change"
/>*/}
{/*<Form
schema={removeKeyRecursively(schema.media, "pattern") as any}
initialValues={config.media}
>
<AutoForm />
</Form>*/}
{/*<Form
schema={removeKeyRecursively(schema.server, "pattern") as any}
initialValues={config.server}
>
<AutoForm />
</Form>*/}
{/*<Form schema={ss} validateOn="change" />*/}
</div>
</Scrollable>
);
}
const ss = {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string", format: "email" },
interested: { type: "boolean" },
bla: {
type: "string",
enum: ["small", "medium", "large"]
},
password: { type: "string", format: "password" },
birthdate: { type: "string", format: "date" },
dinnerTime: { type: "string", format: "date-time" },
age: { type: "number", minimum: 0, multipleOf: 5 },
tags: {
type: "array",
items: {
type: "string"
}
},
config: {
type: "object",
properties: {
min: { type: "number" }
}
}
},
required: ["name"],
additionalProperties: false
} as const satisfies JSONSchema;
function CustomMediaForm() {
const { schema: _schema, config } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema));
config.media.storage.body_max_size = 1;
schema.media.properties.storage.properties.body_max_size.minimum = 0;
schema.media.if = { properties: { enabled: { const: true } } };
// biome-ignore lint/suspicious/noThenProperty: <explanation>
schema.media.then = { required: ["adapter"] };
return (
<Form
schema={schema.media}
/*initialValues={config.media as any}*/
className="flex flex-col gap-3"
validateOn="change"
>
<Test />
<Field name="enabled" />
<Field name="basepath" />
<Field name="entity_name" />
<Field name="storage" />
<AnyOf.Root path="adapter">
<CustomMediaFormAdapter />
</AnyOf.Root>
{/*<FormDebug force />*/}
</Form>
);
}
const Test = () => {
const errors = useFormError("", { strict: true });
return <div>{errors.map((e) => e.message).join("\n")}</div>;
//return <pre>{JSON.stringify(errors, null, 2)}</pre>;
};
function CustomMediaFormAdapter() {
const ctx = AnyOf.useContext();
return (
<>
<div className="flex flex-row gap-1">
{ctx.schemas?.map((schema: any, i) => (
<Button
key={i}
onClick={() => ctx.select(i)}
variant={ctx.selected === i ? "primary" : "default"}
>
{schema.title ?? `Option ${i + 1}`}
</Button>
))}
</div>
{ctx.selected !== null && (
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
<Field name="type" hidden />
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
</FormContextOverride>
)}
</>
);
}

View File

@@ -16,34 +16,6 @@ html.fixed body {
touch-action: none; touch-action: none;
} }
#bknd-admin,
.bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */
--color-background: 250 250 250; /* zinc-50 */
--color-muted: 228 228 231; /* ? */
--color-darkest: 0 0 0; /* black */
--color-lightest: 255 255 255; /* white */
&.dark {
--color-primary: 250 250 250; /* zinc-50 */
--color-background: 30 31 34;
--color-muted: 47 47 52;
--color-darkest: 255 255 255; /* white */
--color-lightest: 24 24 27; /* black */
}
@mixin light {
--mantine-color-body: rgb(250 250 250);
}
@mixin dark {
--mantine-color-body: rgb(9 9 11);
}
table {
font-size: inherit;
}
}
html, html,
body { body {
font-size: 14px; font-size: 14px;
@@ -63,9 +35,8 @@ body {
} }
div[data-radix-scroll-area-viewport] > div:first-child { div[data-radix-scroll-area-viewport] > div:first-child {
min-width: auto !important;
display: block !important; display: block !important;
min-width: 100% !important;
max-width: 100%;
} }
/* hide calendar icon on inputs */ /* hide calendar icon on inputs */

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": ["bun-types", "@cloudflare/workers-types"], "types": ["bun-types", "@cloudflare/workers-types"],
"composite": true, "composite": false,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx", "jsx": "react-jsx",

BIN
bun.lockb

Binary file not shown.

View File

@@ -65,7 +65,7 @@ import "bknd/dist/styles.css";
import { getApi } from "bknd/adapter/astro"; import { getApi } from "bknd/adapter/astro";
const api = getApi(Astro, { mode: "dynamic" }); const api = await getApi(Astro, { mode: "dynamic" });
const user = api.getUser(); const user = api.getUser();
export const prerender = false; export const prerender = false;
@@ -94,7 +94,7 @@ Here is an example of using the API in static context:
```jsx ```jsx
--- ---
import { getApi } from "bknd/adapter/astro"; import { getApi } from "bknd/adapter/astro";
const api = getApi(Astro); const api = await getApi(Astro);
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
--- ---
@@ -109,7 +109,7 @@ On SSR pages, you can also access the authenticated user:
```jsx ```jsx
--- ---
import { getApi } from "bknd/adapter/astro"; import { getApi } from "bknd/adapter/astro";
const api = getApi(Astro, { mode: "dynamic" }); const api = await getApi(Astro, { mode: "dynamic" });
const user = api.getUser(); const user = api.getUser();
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");

View File

@@ -16,11 +16,11 @@ and then install bknd as a dependency:
If you don't choose anything specific, the following code will use the `warm` mode. See the If you don't choose anything specific, the following code will use the `warm` mode. See the
chapter [Using a different mode](#using-a-different-mode) for available modes. chapter [Using a different mode](#using-a-different-mode) for available modes.
``` ts ```ts src/index.ts
import { serve } from "bknd/adapter/cloudflare"; import { serve } from "bknd/adapter/cloudflare";
export default serve({ export default serve<Env>({
app: (env: Env) => ({ app: ({ env }) => ({
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
@@ -42,32 +42,37 @@ And confirm it works by opening [http://localhost:8787](http://localhost:8787) i
your browser. your browser.
## Serve the Admin UI ## Serve the Admin UI
Now in order to also server the static admin files, you have to modify the `wrangler.toml` to Now in order to also server the static admin files, you have to modify the `wrangler.toml` to include the static assets. You can do so by either serving the static using the new [Assets feature](https://developers.cloudflare.com/workers/static-assets/), or the deprecated [Workers Site](https://developers.cloudflare.com/workers/configuration/sites/configuration/).
include the static assets:
```toml
[site]
bucket = "node_modules/bknd/dist/static"
```
And then modify the worker entry as follows: <Tabs>
``` ts {2, 14, 15} <Tab title="Assets">
import { serve } from "bknd/adapter/cloudflare"; Make sure your assets point to the static assets included in the bknd package:
import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve({ ```toml wrangler.toml
app: (env: Env) => ({ assets = { directory = "node_modules/bknd/dist/static" }
connection: { ```
type: "libsql",
config: { </Tab>
url: env.DB_URL, <Tab title="Workers Sites">
authToken: env.DB_TOKEN Make sure your site points to the static assets included in the bknd package:
}
} ```toml wrangler.toml
}), [site]
manifest, bucket = "node_modules/bknd/dist/static"
setAdminHtml: true ```
});
``` And then modify the worker entry as follows:
```ts {2, 6} src/index.ts
import { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve<Env>({
app: () => ({/* ... */}),
manifest
});
```
</Tab>
</Tabs>
## Adding custom routes ## Adding custom routes
You can also add custom routes by defining them after the app has been built, like so: You can also add custom routes by defining them after the app has been built, like so:
@@ -75,8 +80,8 @@ You can also add custom routes by defining them after the app has been built, li
import { serve } from "bknd/adapter/cloudflare"; import { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST"; import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve({ export default serve<Env>({
app: (env: Env) => ({ app: ({ env }) => ({
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
@@ -111,7 +116,7 @@ mode`, like so:
import { serve } from "bknd/adapter/cloudflare"; import { serve } from "bknd/adapter/cloudflare";
export default serve({ export default serve({
/* ... */, // ...
mode: "fresh" // mode: "fresh" | "warm" | "cache" | "durable" mode: "fresh" // mode: "fresh" | "warm" | "cache" | "durable"
}); });
``` ```
@@ -119,13 +124,14 @@ export default serve({
### Mode: `cache` ### Mode: `cache`
For the cache mode to work, you also need to specify the KV to be used. For this, use the For the cache mode to work, you also need to specify the KV to be used. For this, use the
`bindings` property: `bindings` property:
```ts ```ts
import { serve } from "bknd/adapter/cloudflare"; import { serve } from "bknd/adapter/cloudflare";
export default serve({ export default serve<Env>({
/* ... */, // ...
mode: "cache", mode: "cache",
bindings: (env: Env) => ({ kv: env.KV }) bindings: ({ env }) => ({ kv: env.KV })
}); });
``` ```
@@ -136,10 +142,10 @@ environment, and additionally export the `DurableBkndApp` class:
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
export { DurableBkndApp }; export { DurableBkndApp };
export default serve({ export default serve<Env>({
/* ... */, // ...
mode: "durable", mode: "durable",
bindings: (env: Env) => ({ dobj: env.DOBJ }), bindings: ({ env }) => ({ dobj: env.DOBJ }),
keepAliveSeconds: 60 // optional keepAliveSeconds: 60 // optional
}); });
``` ```
@@ -164,9 +170,9 @@ import type { App } from "bknd";
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
export default serve({ export default serve({
/* ... */, // ...
mode: "durable", mode: "durable",
bindings: (env: Env) => ({ dobj: env.DOBJ }), bindings: ({ env }) => ({ dobj: env.DOBJ }),
keepAliveSeconds: 60 // optional keepAliveSeconds: 60 // optional
}); });

View File

@@ -10,7 +10,7 @@ Install bknd as a dependency:
## Serve the API ## Serve the API
Create a new api splat route file at `app/routes/api.$.ts`: Create a new api splat route file at `app/routes/api.$.ts`:
``` tsx ```ts
// app/routes/api.$.ts // app/routes/api.$.ts
import { serve } from "bknd/adapter/remix"; import { serve } from "bknd/adapter/remix";
@@ -32,6 +32,9 @@ Now make sure that you wrap your root layout with the `ClientProvider` so that a
share the same context: share the same context:
```tsx ```tsx
// app/root.tsx // app/root.tsx
import { withApi } from "bknd/adapter/remix"
import { type Api, ClientProvider } from "bknd/client";
export function Layout(props) { export function Layout(props) {
// nothing to change here, just for orientation // nothing to change here, just for orientation
return ( return (
@@ -48,21 +51,12 @@ declare module "@remix-run/server-runtime" {
} }
// export a loader that initiates the API // export a loader that initiates the API
// and pass it through the context // and passes it down to args.context.api
export const loader = async (args: LoaderFunctionArgs) => { export const loader = withApi(async (args: LoaderFunctionArgs, api: Api) => {
const api = new Api({ return {
host: new URL(args.request.url).origin, user: api.getUser()
headers: args.request.headers };
}); });
// get the user from the API
const user = api.getUser();
// add api to the context
args.context.api = api;
return { user };
};
export default function App() { export default function App() {
const { user } = useLoaderData<typeof loader>(); const { user } = useLoaderData<typeof loader>();
@@ -93,15 +87,9 @@ Since the API has already been constructed in the root layout, you can now use i
import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useLoaderData } from "@remix-run/react"; import { useLoaderData } from "@remix-run/react";
export const loader = async (args: LoaderFunctionArgs) => { export const loader = async ({ context: { api } }: LoaderFunctionArgs) => {
const { api } = args.context;
// get the authenticated user
const user = api.getAuthState().user;
// get the data from the API
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
return { data, user }; return { data, user: api.getUser() };
}; };
export default function Index() { export default function Index() {

View File

@@ -5,19 +5,74 @@ description: 'Use the bknd SDK in TypeScript'
To start using the bknd API, start by creating a new API instance: To start using the bknd API, start by creating a new API instance:
```ts ```ts
import { Api } from "bknd"; import { Api } from "bknd/client";
const api = new Api({ const api = new Api();
host: "..." // point to your bknd instance
});
// make sure to verify auth // always make sure to verify auth
await api.verifyAuth(); await api.verifyAuth();
``` ```
The `Api` class is the main entry point for interacting with the bknd API. It provides methods The `Api` class is the main entry point for interacting with the bknd API. It provides methods
for all available modules described below. for all available modules described below.
## Setup
You can initialize an API instance by providing the `Request` object, or manually specifying the details such as `host` and `token`.
### Using the `Request` object
The recommended way to create an API instance is by passing the current `Request` object. This will automatically point the API to your current instance and extract the token from the headers (either from cookies or `Authorization` header):
```ts
import { Api } from "bknd/client";
// replace this with the actual request
let request: Request;
const api = new Api({ request });
```
If the authentication details are contained in the current request, but you're hosting your bknd instance somewhere else, you can specify a `host` option:
```ts
import { Api } from "bknd/client";
// replace this with the actual request
let request: Request;
const api = new Api({
host: "https://<your-endpoint>",
request,
});
```
### Using the `token` option
If you want to have an API instance that is using a different token, e.g. an admin token, you can create it by specifying the `host` and `token` option:
```ts
import { Api } from "bknd/client";
const api = new Api({
host: "https://<your-endpoint>",
token: "<your-token>"
});
```
### Using a local API
In case the place where you're using the API is the same as your bknd instance (e.g. when using it embedded in a React framework), you can specify a `fetcher` option to point to your bknd app. This way, requests won't travel over the network and instead processed locally:
```ts
import type { App } from "bknd";
import { Api } from "bknd/client";
// replace this with your actual `App` instance
let app: App;
const api = new Api({
fetcher: app.server.request as typeof fetch,
// specify `host` and `token` or `request`
});
```
## Data (`api.data`) ## Data (`api.data`)
Access the `Data` specific API methods at `api.data`. Access the `Data` specific API methods at `api.data`.
@@ -79,19 +134,25 @@ const { data } = await api.data.deleteOne("posts", 1);
Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the
API will automatically save the token and use it for subsequent requests. API will automatically save the token and use it for subsequent requests.
### `auth.loginWithPassword([input])` ### `auth.strategies()`
To log in with a password, use the `loginWithPassword` method: To retrieve the available authentication strategies, use the `strategies` method:
```ts ```ts
const { data } = await api.auth.loginWithPassword({ const { data } = await api.auth.strategies();
```
### `auth.login([strategy], [input])`
To log in with a password, use the `login` method:
```ts
const { data } = await api.auth.login("password", {
email: "...", email: "...",
password: "..." password: "..."
}); });
``` ```
### `auth.registerWithPassword([input])` ### `auth.register([strategy], [input])`
To register with a password, use the `registerWithPassword` method: To register with a password, use the `register` method:
```ts ```ts
const { data } = await api.auth.registerWithPassword({ const { data } = await api.auth.register("password", {
email: "...", email: "...",
password: "..." password: "..."
}); });
@@ -103,8 +164,3 @@ To retrieve the current user, use the `me` method:
const { data } = await api.auth.me(); const { data } = await api.auth.me();
``` ```
### `auth.strategies()`
To retrieve the available authentication strategies, use the `strategies` method:
```ts
const { data } = await api.auth.strategies();
```

View File

@@ -4,8 +4,7 @@ import "bknd/dist/styles.css";
import { getApi } from "bknd/adapter/astro"; import { getApi } from "bknd/adapter/astro";
const api = getApi(Astro, { mode: "dynamic" }); const api = await getApi(Astro, { mode: "dynamic" });
await api.verifyAuth();
const user = api.getUser(); const user = api.getUser();
export const prerender = false; export const prerender = false;

View File

@@ -1,6 +1,6 @@
import type { APIContext } from "astro";
import { App } from "bknd"; import { App } from "bknd";
import { serve } from "bknd/adapter/astro"; import { registerLocalMediaAdapter, serve } from "bknd/adapter/astro";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { boolean, em, entity, text } from "bknd/data"; import { boolean, em, entity, text } from "bknd/data";
import { secureRandomString } from "bknd/utils"; import { secureRandomString } from "bknd/utils";
@@ -23,7 +23,7 @@ declare module "bknd/core" {
interface DB extends Database {} interface DB extends Database {}
} }
export const ALL = serve({ export const ALL = serve<APIContext>({
// we can use any libsql config, and if omitted, uses in-memory // we can use any libsql config, and if omitted, uses in-memory
connection: { connection: {
type: "libsql", type: "libsql",

View File

@@ -2,7 +2,8 @@
import { getApi } from "bknd/adapter/astro"; import { getApi } from "bknd/adapter/astro";
import Card from "../components/Card.astro"; import Card from "../components/Card.astro";
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
const api = getApi(Astro);
const api = await getApi(Astro);
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
--- ---

View File

@@ -2,8 +2,7 @@
import { getApi } from "bknd/adapter/astro"; import { getApi } from "bknd/adapter/astro";
import Card from "../components/Card.astro"; import Card from "../components/Card.astro";
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
const api = getApi(Astro, { mode: "dynamic" }); const api = await getApi(Astro, { mode: "dynamic" });
await api.verifyAuth();
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
const user = api.getUser(); const user = api.getUser();

View File

@@ -1,9 +1,7 @@
import { serve } from "bknd/adapter/cloudflare"; import { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve({ export default serve({
app: (env: Env) => ({ app: (args) => ({
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
@@ -13,6 +11,5 @@ export default serve({
}), }),
onBuilt: async (app) => { onBuilt: async (app) => {
app.modules.server.get("/custom", (c) => c.json({ hello: "world" })); app.modules.server.get("/custom", (c) => c.json({ hello: "world" }));
}, }
manifest
}); });

View File

@@ -5,9 +5,10 @@ compatibility_date = "2024-11-06"
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
workers_dev = true workers_dev = true
minify = true minify = true
assets = { directory = "../../app/dist/static" }
[observability] [observability]
enabled = true enabled = true
[site] #[site]
bucket = "../../app/dist/static" #bucket = "../../app/dist/static"

View File

@@ -1,12 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/node"; import type { LoaderFunctionArgs } from "@remix-run/node";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
import { Api, ClientProvider } from "bknd/client"; import { withApi } from "bknd/adapter/remix";
import { type Api, ClientProvider } from "bknd/client";
declare module "@remix-run/server-runtime" {
export interface AppLoadContext {
api: Api;
}
}
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
@@ -26,20 +21,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
); );
} }
export const loader = async (args: LoaderFunctionArgs) => { declare module "@remix-run/server-runtime" {
const api = new Api({ export interface AppLoadContext {
host: new URL(args.request.url).origin, api: Api;
headers: args.request.headers }
}); }
// add api to the context export const loader = withApi(async (args: LoaderFunctionArgs, api: Api) => {
args.context.api = api;
await api.verifyAuth();
return { return {
user: api.getAuthState()?.user user: api.getUser()
}; };
}; });
export default function App() { export default function App() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();

View File

@@ -1,19 +1,20 @@
import { type MetaFunction, useLoaderData } from "@remix-run/react"; import { type MetaFunction, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useAuth } from "bknd/client";
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [{ title: "Remix & bknd" }, { name: "description", content: "Welcome to Remix & bknd!" }]; return [{ title: "Remix & bknd" }, { name: "description", content: "Welcome to Remix & bknd!" }];
}; };
export const loader = async (args: LoaderFunctionArgs) => { export const loader = async ({ context: { api } }: LoaderFunctionArgs) => {
const api = args.context.api;
await api.verifyAuth();
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
return { data, user: api.getUser() }; return { data, user: api.getUser() };
}; };
export default function Index() { export default function Index() {
const { data, user } = useLoaderData<typeof loader>(); const { data, user } = useLoaderData<typeof loader>();
const auth = useAuth();
console.log("auth", auth);
return ( return (
<div> <div>

View File

@@ -1,6 +1,5 @@
import { App } from "bknd"; import { App } from "bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { registerLocalMediaAdapter, serve } from "bknd/adapter/remix";
import { serve } from "bknd/adapter/remix";
import { boolean, em, entity, text } from "bknd/data"; import { boolean, em, entity, text } from "bknd/data";
import { secureRandomString } from "bknd/utils"; import { secureRandomString } from "bknd/utils";

View File

@@ -26,6 +26,7 @@ type BkndContextProps = {
}; };
const BkndContextContext = createContext<BkndGlobalContextProps>({} as any); const BkndContextContext = createContext<BkndGlobalContextProps>({} as any);
BkndContextContext.displayName = "BkndContext";
export const BkndContext = ({ export const BkndContext = ({
children, children,