Merge remote-tracking branch 'origin/release/0.10' into feat/add-postgres-and-prepare-others

# Conflicts:
#	app/package.json
#	bun.lock
This commit is contained in:
dswbx
2025-03-14 08:06:50 +01:00
80 changed files with 2566 additions and 2404 deletions

View File

@@ -32,7 +32,7 @@ describe("MediaApi", () => {
host, host,
basepath, basepath,
}); });
expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`); expect(api.getFileUploadUrl({ path: "path" } as any)).toBe(`${host}${basepath}/upload/path`);
}); });
it("should have correct upload headers", () => { it("should have correct upload headers", () => {

View File

@@ -7,8 +7,8 @@ describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({ const strategy = new OAuthStrategy({
type: "oidc", type: "oidc",
client: { client: {
client_id: process.env.OAUTH_CLIENT_ID, client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET, client_secret: process.env.OAUTH_CLIENT_SECRET!,
}, },
name: "google", name: "google",
}); });
@@ -19,11 +19,6 @@ describe("OAuthStrategy", async () => {
const config = await strategy.getConfig(); const config = await strategy.getConfig();
console.log("config", JSON.stringify(config, null, 2)); console.log("config", JSON.stringify(config, null, 2));
const request = await strategy.request({
redirect_uri,
state,
});
const server = Bun.serve({ const server = Bun.serve({
fetch: async (req) => { fetch: async (req) => {
const url = new URL(req.url); const url = new URL(req.url);
@@ -39,6 +34,11 @@ describe("OAuthStrategy", async () => {
return new Response("Bun!"); return new Response("Bun!");
}, },
}); });
const request = await strategy.request({
redirect_uri,
state,
});
console.log("request", request); console.log("request", request);
await new Promise((resolve) => setTimeout(resolve, 100000)); await new Promise((resolve) => setTimeout(resolve, 100000));

View File

@@ -4,6 +4,9 @@ import Database from "libsql";
import { format as sqlFormat } from "sql-formatter"; import { format as sqlFormat } from "sql-formatter";
import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data"; import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data";
import type { em as protoEm } from "../src/data/prototype"; import type { em as protoEm } from "../src/data/prototype";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { slugify } from "core/utils/strings";
export function getDummyDatabase(memory: boolean = true): { export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase; dummyDb: SqliteDatabase;
@@ -71,3 +74,46 @@ export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): En
export const assetsPath = `${import.meta.dir}/_assets`; export const assetsPath = `${import.meta.dir}/_assets`;
export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
export async function enableFetchLogging() {
const originalFetch = global.fetch;
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init);
const url = input instanceof URL || typeof input === "string" ? input : input.url;
// Only clone if it's a supported content type
const contentType = response.headers.get("content-type") || "";
const isSupported =
contentType.includes("json") ||
contentType.includes("text") ||
contentType.includes("xml");
if (isSupported) {
const clonedResponse = response.clone();
let extension = "txt";
let body: string;
if (contentType.includes("json")) {
body = JSON.stringify(await clonedResponse.json(), null, 2);
extension = "json";
} else if (contentType.includes("xml")) {
body = await clonedResponse.text();
extension = "xml";
} else {
body = await clonedResponse.text();
}
const fileName = `${new Date().getTime()}_${init?.method ?? "GET"}_${slugify(String(url))}.${extension}`;
const filePath = join(assetsTmpPath, fileName);
await writeFile(filePath, body);
}
return response;
};
return () => {
global.fetch = originalFetch;
};
}

View File

@@ -39,8 +39,8 @@ function makeName(ext: string) {
return randomString(10) + "." + ext; return randomString(10) + "." + ext;
} }
/*beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);*/ afterAll(enableConsoleLog);
describe("MediaController", () => { describe("MediaController", () => {
test.only("accepts direct", async () => { test.only("accepts direct", async () => {
@@ -56,9 +56,9 @@ describe("MediaController", () => {
console.log(result); console.log(result);
expect(result.name).toBe(name); expect(result.name).toBe(name);
/*const destFile = Bun.file(assetsTmpPath + "/" + name); const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true); expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();*/ await destFile.delete();
}); });
test("accepts form data", async () => { test("accepts form data", async () => {

View File

@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils"; import { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media"; import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv"; import { config } from "dotenv";
//import { enableFetchLogging } from "../../helper";
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!; dotenvOutput.parsed!;
@@ -11,7 +12,17 @@ const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_
const ALL_TESTS = !!process.env.ALL_TESTS; const ALL_TESTS = !!process.env.ALL_TESTS;
console.log("ALL_TESTS?", ALL_TESTS); console.log("ALL_TESTS?", ALL_TESTS);
describe.skipIf(true)("StorageS3Adapter", async () => { /*
// @todo: preparation to mock s3 calls + replace fast-xml-parser
let cleanup: () => void;
beforeAll(async () => {
cleanup = await enableFetchLogging();
});
afterAll(() => {
cleanup();
}); */
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
if (ALL_TESTS) return; if (ALL_TESTS) return;
const versions = [ const versions = [
@@ -66,7 +77,7 @@ describe.skipIf(true)("StorageS3Adapter", async () => {
test.skipIf(disabled("putObject"))("puts an object", async () => { test.skipIf(disabled("putObject"))("puts an object", async () => {
objects = (await adapter.listObjects()).length; objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file)).toBeString(); expect(await adapter.putObject(filename, file as any)).toBeString();
}); });
test.skipIf(disabled("listObjects"))("lists objects", async () => { test.skipIf(disabled("listObjects"))("lists objects", async () => {

View File

@@ -4,6 +4,7 @@ import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "../../src/data"; import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
// @ts-ignore
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
describe("AppAuth", () => { describe("AppAuth", () => {
@@ -22,7 +23,7 @@ describe("AppAuth", () => {
const config = auth.toJSON(); const config = auth.toJSON();
expect(config.jwt).toBeUndefined(); expect(config.jwt).toBeUndefined();
expect(config.strategies.password.config).toBeUndefined(); expect(config.strategies?.password?.config).toBeUndefined();
}); });
test("enabling auth: generate secret", async () => { test("enabling auth: generate secret", async () => {
@@ -42,6 +43,7 @@ describe("AppAuth", () => {
const auth = new AppAuth( const auth = new AppAuth(
{ {
enabled: true, enabled: true,
// @ts-ignore
jwt: { jwt: {
secret: "123456", secret: "123456",
}, },
@@ -75,7 +77,7 @@ describe("AppAuth", () => {
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1); expect(users.length).toBe(1);
expect(users[0].email).toBe("some@body.com"); expect(users[0]?.email).toBe("some@body.com");
} }
}); });
@@ -157,7 +159,7 @@ describe("AppAuth", () => {
const authField = make(name, _authFieldProto as any); const authField = make(name, _authFieldProto as any);
const field = users.field(name)!; const field = users.field(name)!;
for (const prop of props) { for (const prop of props) {
expect(field.config[prop]).toBe(authField.config[prop]); expect(field.config[prop]).toEqual(authField.config[prop]);
} }
} }
}); });

View File

@@ -43,7 +43,7 @@ describe("ModuleManager", async () => {
}).toJSON(), }).toJSON(),
}, },
}, },
}); }) as any;
//const { version, ...json } = mm.toJSON() as any; //const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -73,7 +73,7 @@ describe("ModuleManager", async () => {
}).toJSON(), }).toJSON(),
}, },
}, },
}; } as any;
//const { version, ...json } = mm.toJSON() as any; //const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -90,23 +90,20 @@ describe("ModuleManager", async () => {
await mm2.build(); await mm2.build();
expect(stripMark(json)).toEqual(stripMark(mm2.configs())); expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities.test).toBeDefined(); expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities.test.fields.content).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined(); expect(mm2.get("data").toJSON().entities?.test?.fields?.content).toBeDefined();
}); });
test("s4: config given, table exists, version outdated, migrate", async () => { test("s4: config given, table exists, version outdated, migrate", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
const version = mm.version();
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely; const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, { const mm2 = new ModuleManager(c2.dummyConnection);
initial: { version: version - 1, ...json },
});
await mm2.syncConfigTable(); await mm2.syncConfigTable();
await db await db
@@ -171,7 +168,7 @@ describe("ModuleManager", async () => {
expect(mm2.configs().data.basepath).toBe("/api/data2"); expect(mm2.configs().data.basepath).toBe("/api/data2");
}); });
test("blank app, modify config", async () => { /*test("blank app, modify config", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection); const mm = new ModuleManager(dummyConnection);
@@ -194,7 +191,7 @@ describe("ModuleManager", async () => {
}, },
}, },
}); });
}); });*/
test("partial config given", async () => { test("partial config given", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -212,13 +209,15 @@ describe("ModuleManager", async () => {
expect(mm.version()).toBe(CURRENT_VERSION); expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true); expect(mm.built()).toBe(true);
expect(mm.configs().auth.enabled).toBe(true); expect(mm.configs().auth.enabled).toBe(true);
expect(mm.configs().data.entities.users).toBeDefined(); expect(mm.configs().data.entities?.users).toBeDefined();
}); });
test("partial config given, but db version exists", async () => { test("partial config given, but db version exists", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -265,8 +264,8 @@ describe("ModuleManager", async () => {
override async build() { override async build() {
//console.log("building FailingModule", this.config); //console.log("building FailingModule", this.config);
if (this.config.value < 0) { if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive"); throw new Error("value must be positive, given: " + this.config.value);
} }
this.setBuilt(); this.setBuilt();
} }
@@ -332,6 +331,7 @@ describe("ModuleManager", async () => {
// @ts-ignore // @ts-ignore
const f = mm.mutateConfigSafe("failing"); const f = mm.mutateConfigSafe("failing");
// @ts-ignore
expect(f.set({ value: 2 })).resolves.toBeDefined(); expect(f.set({ value: 2 })).resolves.toBeDefined();
expect(mockOnUpdated).toHaveBeenCalled(); expect(mockOnUpdated).toHaveBeenCalled();
}); });

View File

@@ -1,39 +1,44 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src"; import { type InitialModuleConfigs, createApp } from "../../../src";
import type { Kysely } from "kysely"; import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper"; import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json"; import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json";
// app expects migratable config to be present in database // app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs) { async function createVersionedApp(config: InitialModuleConfigs | any) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version"); if (!("version" in config)) throw new Error("config must have a version");
const { version, ...rest } = config; const { version, ...rest } = config;
const app = createApp({ connection: dummyConnection }); const db = dummyConnection.kysely as Kysely<any>;
await app.build(); await sql`CREATE TABLE "__bknd" (
"id" integer not null primary key autoincrement,
"version" integer,
"type" text,
"json" text,
"created_at" datetime,
"updated_at" datetime
)`.execute(db);
const qb = app.modules.ctx().connection.kysely as Kysely<any>; await db
const current = await qb .insertInto("__bknd")
.selectFrom("__bknd") .values({
.selectAll() version,
.where("type", "=", "config") type: "config",
.executeTakeFirst(); created_at: new Date().toISOString(),
json: JSON.stringify(rest),
await qb })
.updateTable("__bknd")
.set("json", JSON.stringify(rest))
.set("version", 7)
.where("id", "=", current!.id)
.execute(); .execute();
const app2 = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
}); });
await app2.build(); await app.build();
return app2; return app;
} }
describe("Migrations", () => { describe("Migrations", () => {
@@ -46,8 +51,8 @@ describe("Migrations", () => {
const app = await createVersionedApp(v7); const app = await createVersionedApp(v7);
expect(app.version()).toBe(8); expect(app.version()).toBeGreaterThan(7);
expect(app.toJSON(true).auth.strategies.password.enabled).toBe(true); expect(app.toJSON(true).auth.strategies?.password?.enabled).toBe(true);
const req = await app.server.request("/api/auth/password/register", { const req = await app.server.request("/api/auth/password/register", {
method: "POST", method: "POST",
@@ -60,7 +65,17 @@ describe("Migrations", () => {
}), }),
}); });
expect(req.ok).toBe(true); expect(req.ok).toBe(true);
const res = await req.json(); const res = (await req.json()) as any;
expect(res.user.email).toBe("test@test.com"); expect(res.user.email).toBe("test@test.com");
}); });
test("migration from 8 to 9", async () => {
expect(v8.version).toBe(8);
const app = await createVersionedApp(v8);
expect(app.version()).toBeGreaterThan(8);
// @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined();
});
}); });

View File

@@ -0,0 +1,709 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"color_scheme": "light",
"logo_return_path": "/"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"brand": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"currency": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"price": {
"type": "number",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"price_compare": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"url": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"required": true,
"fillable": true,
"hidden": ["table"]
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"description": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 4
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products"
}
},
"identifier": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "jsonschema",
"config": {
"schema": {
"type": "object",
"properties": {
"size": {
"type": "string"
},
"gender": {
"type": "string"
},
"ai_description": {
"type": "string"
}
},
"additionalProperties": {
"type": ["string", "number"]
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"_likes": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"temporary": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password"]
},
"required": true,
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"username": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"name": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"_boards": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"product_likes": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "User",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Product",
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc",
"name": "Product Likes"
}
},
"boards": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"private": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"description": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "boards",
"max_items": 5
}
},
"cover": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
},
"_products": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"boards_products": {
"type": "generated",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"boards_id": {
"type": "relation",
"config": {
"required": true,
"reference": "boards",
"target": "boards",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"manual": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
}
},
"relations": {
"poly_products_media_images": {
"type": "poly",
"source": "products",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_product_likes_users": {
"type": "n:1",
"source": "product_likes",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_product_likes_products": {
"type": "n:1",
"source": "product_likes",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_boards_users": {
"type": "n:1",
"source": "boards",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"poly_boards_media_images": {
"type": "poly",
"source": "boards",
"target": "media",
"config": {
"mappedBy": "images",
"targetCardinality": 5
}
},
"mn_boards_products_boards_products,boards_products": {
"type": "m:n",
"source": "boards",
"target": "products",
"config": {}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_product_likes_unique_products_id_users_id": {
"entity": "product_likes",
"fields": ["products_id", "users_id"],
"unique": true
},
"idx_boards_products_unique_boards_id_products_id": {
"entity": "boards_products",
"fields": ["boards_id", "products_id"],
"unique": true
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_products_identifier": {
"entity": "products",
"fields": ["identifier"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "2wY76Z$JQg(3t?Wn8g,^ZqZhlmjNx<@uQjI!7i^XZBF11Xa1>zZK2??Y[D|]cc%k",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
}
},
"roles": {
"guest": {
"is_default": true,
"permissions": ["system.access.api", "data.entity.read"]
},
"admin": {
"implicit_allow": true
}
},
"guard": {
"enabled": true
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/enly"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}

View File

@@ -0,0 +1,598 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"logo_return_path": "/",
"color_scheme": "dark"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"posts": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"slug": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"pattern": "^[a-z\\-\\_0-9]+$",
"required": false,
"fillable": true,
"hidden": false,
"label": "Slug"
}
},
"content": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 5
}
},
"required": false,
"fillable": true,
"hidden": ["form"]
}
},
"active": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "posts"
}
},
"tags": {
"type": "jsonschema",
"config": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"ui_schema": {
"ui:options": {
"orderable": false
}
},
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "title",
"sort_dir": "desc"
}
},
"comments": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"content": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": false
}
},
"posts_id": {
"type": "relation",
"config": {
"label": "Posts",
"required": true,
"reference": "posts",
"target": "posts",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password", "google"]
},
"required": true,
"fillable": ["create"],
"hidden": ["update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin", "editor"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"avatar": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "users",
"max_items": 1
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"test": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"number": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {
"n1_comments_posts": {
"type": "n:1",
"source": "comments",
"target": "posts",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_posts_media_images": {
"type": "poly",
"source": "posts",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_posts_users": {
"type": "n:1",
"source": "posts",
"target": "users",
"config": {
"with_limit": 5
}
},
"n1_comments_users": {
"type": "n:1",
"source": "comments",
"target": "users",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_users_media_avatar": {
"type": "poly",
"source": "users",
"target": "media",
"config": {
"mappedBy": "avatar",
"targetCardinality": 1
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"jwt": {
"secret": "A%3jk*wD!Zruj123123123j$Wm8qS8m8qS8",
"alg": "HS256",
"fields": ["id", "email", "role"],
"issuer": "showoff"
},
"guard": {
"enabled": true
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
},
"google": {
"enabled": true,
"type": "oauth",
"config": {
"type": "oidc",
"client": {
"client_id": "545948917277-123ieuifrag.apps.googleusercontent.com",
"client_secret": "123-123hTTZfDDGPDPp"
},
"name": "google"
}
}
},
"roles": {
"guest": {
"permissions": [
"data.entity.read",
"system.access.api",
"system.config.read"
],
"is_default": true
},
"admin": {
"is_default": false,
"implicit_allow": true
},
"editor": {
"permissions": [
"system.access.admin",
"system.config.read",
"system.schema.read",
"system.config.read.secrets",
"system.access.api",
"data.entity.read",
"data.entity.update",
"data.entity.delete",
"data.entity.create"
]
}
},
"allow_register": true,
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/bknd-123"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {
"test": {
"trigger": {
"type": "http",
"config": {
"mode": "sync",
"method": "GET",
"response_type": "html",
"path": "/json-posts"
}
},
"tasks": {
"fetching": {
"type": "fetch",
"params": {
"method": "GET",
"headers": [],
"url": "https://jsonplaceholder.typicode.com/posts"
}
},
"render": {
"type": "render",
"params": {
"render": "<h1>Posts</h1>\n<ul>\n {% for post in fetching.output %}\n <li>{{ post.title }}</li>\n {% endfor %}\n</ul>"
}
}
},
"connections": {
"5cce66b5-57c6-4541-88ac-b298794c6c52": {
"source": "fetching",
"target": "render",
"config": {
"condition": {
"type": "success"
}
}
}
},
"start_task": "fetching",
"responding_task": "render"
}
}
}
}

View File

@@ -1,13 +0,0 @@
//import type { BkndConfig } from "./src";
export default {
app: {
connection: {
type: "libsql",
config: {
//url: "http://localhost:8080"
url: ":memory:"
}
}
}
};

View File

@@ -1,257 +0,0 @@
import { $, type Subprocess } from "bun";
import * as esbuild from "esbuild";
import postcss from "esbuild-postcss";
import { entryOutputMeta } from "./internal/esbuild.entry-output-meta.plugin";
import { guessMimeType } from "./src/media/storage/mime-types";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const minify = args.includes("--minify");
const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
type BuildOptions = esbuild.BuildOptions & { name: string };
const baseOptions: Partial<Omit<esbuild.BuildOptions, "plugins">> & { plugins?: any[] } = {
minify,
sourcemap,
metafile: true,
format: "esm",
drop: ["console", "debugger"],
loader: {
".svg": "dataurl",
},
define: {
__isDev: "0",
},
};
// @ts-ignore
type BuildFn = (format?: "esm" | "cjs") => BuildOptions;
// build BE
const builds: Record<string, BuildFn> = {
backend: (format = "esm") => ({
...baseOptions,
name: `backend ${format}`,
entryPoints: [
"src/index.ts",
"src/data/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/ui/index.ts",
"src/ui/main.css",
],
outdir: "dist",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
platform: "browser",
splitting: false,
bundle: true,
plugins: [postcss()],
//target: "es2022",
format,
}),
/*components: (format = "esm") => ({
...baseOptions,
name: `components ${format}`,
entryPoints: ["src/ui/index.ts", "src/ui/main.css"],
outdir: "dist/ui",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
format,
platform: "browser",
splitting: false,
//target: "es2022",
bundle: true,
//external: ["react", "react-dom", "@tanstack/react-query-devtools"],
plugins: [postcss()],
loader: {
".svg": "dataurl",
".js": "jsx"
}
}),*/
static: (format = "esm") => ({
...baseOptions,
name: `static ${format}`,
entryPoints: ["src/ui/main.tsx", "src/ui/main.css"],
entryNames: "[dir]/[name]-[hash]",
outdir: "dist/static",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
platform: "browser",
bundle: true,
splitting: true,
inject: ["src/ui/inject.js"],
target: "es2022",
format,
loader: {
".svg": "dataurl",
".js": "jsx",
},
define: {
__isDev: "0",
"process.env.NODE_ENV": '"production"',
},
chunkNames: "chunks/[name]-[hash]",
plugins: [
postcss(),
entryOutputMeta(async (info) => {
const manifest: Record<string, object> = {};
const toAsset = (output: string) => {
const name = output.split("/").pop()!;
return {
name,
path: output,
mime: guessMimeType(name),
};
};
for (const { output, meta } of info) {
manifest[meta.entryPoint as string] = toAsset(output);
if (meta.cssBundle) {
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
}
}
const manifest_file = "dist/static/manifest.json";
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
console.log(`Manifest written to ${manifest_file}`, manifest);
}),
],
}),
};
function adapter(adapter: string, overrides: Partial<esbuild.BuildOptions> = {}): BuildOptions {
return {
...baseOptions,
name: `adapter ${adapter} ${overrides?.format === "cjs" ? "cjs" : "esm"}`,
entryPoints: [`src/adapter/${adapter}`],
platform: "neutral",
outfile: `dist/adapter/${adapter}/index.${overrides?.format === "cjs" ? "cjs" : "js"}`,
external: [
"cloudflare:workers",
"@hono*",
"hono*",
"bknd*",
"*.html",
"node*",
"react*",
"next*",
"libsql",
"@libsql*",
],
splitting: false,
treeShaking: true,
bundle: true,
...overrides,
};
}
const adapters = [
adapter("vite", { platform: "node" }),
adapter("cloudflare"),
adapter("nextjs", { platform: "node", format: "esm" }),
adapter("nextjs", { platform: "node", format: "cjs" }),
adapter("remix", { format: "esm" }),
adapter("remix", { format: "cjs" }),
adapter("bun"),
adapter("node", { platform: "node", format: "esm" }),
adapter("node", { platform: "node", format: "cjs" }),
];
const collect = [
builds.static(),
builds.backend(),
//builds.components(),
builds.backend("cjs"),
//builds.components("cjs"),
...adapters,
];
if (watch) {
const _state: {
timeout: Timer | undefined;
cleanup: Subprocess | undefined;
building: Subprocess | undefined;
} = {
timeout: undefined,
cleanup: undefined,
building: undefined,
};
async function rebuildTypes() {
if (!types) return;
if (_state.timeout) {
clearTimeout(_state.timeout);
if (_state.cleanup) _state.cleanup.kill();
if (_state.building) _state.building.kill();
}
_state.timeout = setTimeout(async () => {
_state.cleanup = Bun.spawn(["bun", "clean:types"], {
onExit: () => {
_state.cleanup = undefined;
_state.building = Bun.spawn(["bun", "build:types"], {
onExit: () => {
_state.building = undefined;
console.log("Types rebuilt");
},
});
},
});
}, 1000);
}
for (const { name, ...build } of collect) {
const ctx = await esbuild.context({
...build,
plugins: [
...(build.plugins ?? []),
{
name: "rebuild-notify",
setup(build) {
build.onEnd((result) => {
console.log(`rebuilt ${name} with ${result.errors.length} errors`);
rebuildTypes();
});
},
},
],
});
ctx.watch();
}
} else {
await $`rm -rf dist`;
async function _build() {
let i = 0;
const count = collect.length;
for await (const { name, ...build } of collect) {
await esbuild.build({
...build,
plugins: [
...(build.plugins || []),
{
name: "progress",
setup(build) {
i++;
build.onEnd((result) => {
const errors = result.errors.length;
const from = String(i).padStart(String(count).length);
console.log(`[${from}/${count}] built ${name} with ${errors} errors`);
});
},
},
],
});
}
console.log("All builds complete");
}
async function _buildtypes() {
if (!types) return;
Bun.spawn(["bun", "build:types"], {
onExit: () => {
console.log("Types rebuilt");
},
});
}
await Promise.all([_build(), _buildtypes()]);
}

View File

@@ -46,10 +46,18 @@ if (types && !watch) {
buildTypes(); buildTypes();
} }
function banner(title: string) {
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
}
/** /**
* Building backend and general API * Building backend and general API
*/ */
async function buildApi() { async function buildApi() {
banner("Building API");
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
@@ -109,6 +117,7 @@ async function buildUi() {
}, },
} satisfies tsup.Options; } satisfies tsup.Options;
banner("Building UI");
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
@@ -119,6 +128,7 @@ async function buildUi() {
}, },
}); });
banner("Building Client");
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/client/index.ts"], entry: ["src/ui/client/index.ts"],
@@ -136,6 +146,7 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client" * - ui/client is external, and after built replaced with "bknd/client"
*/ */
async function buildUiElements() { async function buildUiElements() {
banner("Building UI Elements");
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
@@ -205,6 +216,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
} }
async function buildAdapters() { async function buildAdapters() {
banner("Building Adapters");
// base adapter handles // base adapter handles
await tsup.build({ await tsup.build({
...baseConfig(""), ...baseConfig(""),

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.9.0", "version": "0.9.1",
"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": {
@@ -32,83 +32,84 @@
}, },
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^2.0.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1", "@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.17.1",
"@sinclair/typebox": "^0.32.34", "@mantine/hooks": "^7.17.1",
"@tanstack/react-form": "0.19.2", "@sinclair/typebox": "^0.34.30",
"@uiw/react-codemirror": "^4.23.6", "@tanstack/react-form": "^1.0.5",
"@xyflow/react": "^12.3.2", "@uiw/react-codemirror": "^4.23.10",
"aws4fetch": "^1.0.18", "@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^5.0.8",
"hono": "^4.6.12", "hono": "^4.7.4",
"json-schema-form-react": "^0.0.2", "json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7", "json-schema-library": "^10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.4", "kysely": "^0.27.6",
"liquidjs": "^10.15.0", "liquidjs": "^10.21.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"radix-ui": "^1.1.2", "radix-ui": "^1.1.3",
"swr": "^2.2.5" "swr": "^2.3.3"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.2.6", "@hono/typebox-validator": "^0.3.2",
"@hono/vite-dev-server": "^0.17.0", "@hono/vite-dev-server": "^0.19.0",
"@hono/zod-validator": "^0.4.1", "@hookform/resolvers": "^4.1.3",
"@hookform/resolvers": "^3.9.1",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.13.4", "@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.13.4", "@mantine/notifications": "^7.17.1",
"@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", "@tailwindcss/postcss": "^4.0.12",
"@types/pg": "^8.11.11", "@tailwindcss/vite": "^4.0.12",
"@types/react": "^18.3.12", "@types/node": "^22.13.10",
"@types/react-dom": "^18.3.1", "@types/react": "^19.0.10",
"@vitejs/plugin-react": "^4.3.3", "@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.20", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"esbuild-postcss": "^0.0.4", "jotai": "^2.12.2",
"jotai": "^2.10.1",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"pg": "^8.13.3", "pg": "^8.13.3",
"postcss": "^8.4.47", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"react-hook-form": "^7.53.1", "react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "5.2.1", "react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1", "react-json-view-lite": "^2.4.1",
"sql-formatter": "^15.4.9", "sql-formatter": "^15.4.11",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^3.0.2",
"tailwindcss": "^3.4.14", "tailwindcss": "^4.0.12",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.10", "tsc-alias": "^1.8.11",
"tsup": "^8.3.5", "tsup": "^8.4.0",
"vite": "^5.4.10", "vite": "^6.2.1",
"vite-plugin-static-copy": "^2.0.0", "vite-tsconfig-paths": "^5.1.4",
"vite-tsconfig-paths": "^5.0.1", "wouter": "^3.6.0"
"wouter": "^3.3.5"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.13.7" "@hono/node-server": "^1.13.8"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18", "react": "^19.x",
"react-dom": ">=18" "react-dom": "^19.x"
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
@@ -230,4 +231,4 @@
"bun", "bun",
"node" "node"
] ]
} }

View File

@@ -1,9 +1,6 @@
export default { export default {
plugins: { plugins: {
"postcss-import": {}, "@tailwindcss/postcss": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
"postcss-preset-mantine": {}, "postcss-preset-mantine": {},
"postcss-simple-vars": { "postcss-simple-vars": {
variables: { variables: {

View File

@@ -1,15 +1,22 @@
import type { App } from "bknd"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { getRuntimeKey, isNode } from "core/utils"; import { isNode } from "core/utils";
export type NextjsBkndConfig = FrameworkBkndConfig & { export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanRequest?: { searchParams?: string[] }; cleanRequest?: { searchParams?: string[] };
}; };
type NextjsContext = {
env: Record<string, string | undefined>;
};
let app: App; let app: App;
let building: boolean = false; let building: boolean = false;
export async function getApp(config: NextjsBkndConfig) { export async function getApp<Args extends NextjsContext = NextjsContext>(
config: NextjsBkndConfig,
args?: Args,
) {
if (building) { if (building) {
while (building) { while (building) {
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
@@ -19,7 +26,7 @@ export async function getApp(config: NextjsBkndConfig) {
building = true; building = true;
if (!app) { if (!app) {
app = await createFrameworkApp(config); app = await createFrameworkApp(config, args);
await app.build(); await app.build();
} }
building = false; building = false;
@@ -52,7 +59,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) { export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) {
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await getApp(config); app = await getApp(config, { env: process.env ?? {} });
} }
const request = getCleanRequest(req, cleanRequest); const request = getCleanRequest(req, cleanRequest);
return app.fetch(request); return app.fetch(request);

View File

@@ -30,7 +30,11 @@ export async function attachServeStatic(app: any, platform: Platform) {
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform)); app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
} }
export async function startServer(server: Platform, app: any, options: { port: number }) { export async function startServer(
server: Platform,
app: any,
options: { port: number; open?: boolean },
) {
const port = options.port; const port = options.port;
console.log(`Using ${server} serve`); console.log(`Using ${server} serve`);
@@ -55,7 +59,9 @@ export async function startServer(server: Platform, app: any, options: { port: n
const url = `http://localhost:${port}`; const url = `http://localhost:${port}`;
console.info("Server listening on", url); console.info("Server listening on", url);
await open(url); if (options.open) {
await open(url);
}
} }
export async function getConfigPath(filePath?: string) { export async function getConfigPath(filePath?: string) {

View File

@@ -47,6 +47,7 @@ export const run: CliCommand = (program) => {
.choices(PLATFORMS) .choices(PLATFORMS)
.default(isBun ? "bun" : "node"), .default(isBun ? "bun" : "node"),
) )
.addOption(new Option("--no-open", "don't open browser window on start"))
.action(action); .action(action);
}; };
@@ -110,6 +111,7 @@ async function action(options: {
dbUrl?: string; dbUrl?: string;
dbToken?: string; dbToken?: string;
server: Platform; server: Platform;
open?: boolean;
}) { }) {
colorizeConsole(console); colorizeConsole(console);
const configFilePath = await getConfigPath(options.config); const configFilePath = await getConfigPath(options.config);
@@ -145,5 +147,5 @@ async function action(options: {
}); });
} }
await startServer(options.server, app, { port: options.port }); await startServer(options.server, app, { port: options.port, open: options.open });
} }

View File

@@ -30,7 +30,7 @@ export class DebugLogger {
const now = performance.now(); const now = performance.now();
const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last)); const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last));
const indents = " ".repeat(this._context.length); const indents = " ".repeat(Math.max(this._context.length - 1, 0));
const context = const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
console.log(indents, context, time, ...args); console.log(indents, context, time, ...args);

View File

@@ -118,3 +118,17 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
} }
return false; return false;
} }
export function slugify(str: string): string {
return (
String(str)
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
// biome-ignore lint/suspicious/noMisleadingCharacterClass: <explanation>
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
.trim() // trim leading or trailing whitespace
.toLowerCase() // convert to lowercase
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
.replace(/\s+/g, "-") // replace spaces with hyphens
.replace(/-+/g, "-") // remove consecutive hyphens
);
}

View File

@@ -19,8 +19,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
indices: _indices = {}, indices: _indices = {},
} = this.config; } = this.config;
this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities));
const entities = transformObject(_entities, (entityConfig, name) => { const entities = transformObject(_entities, (entityConfig, name) => {
return constructEntity(name, entityConfig); return constructEntity(name, entityConfig);
}); });
@@ -60,7 +58,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
); );
this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.ctx.logger.clear();
this.setBuilt(); this.setBuilt();
} }

View File

@@ -115,7 +115,7 @@ const configJsonSchema = Type.Union([
}), }),
), ),
]); ]);
const __bknd = entity(TABLE_NAME, { export const __bknd = entity(TABLE_NAME, {
version: number().required(), version: number().required(),
type: enumm({ enum: ["config", "diff", "backup"] }).required(), type: enumm({ enum: ["config", "diff", "backup"] }).required(),
json: jsonSchema({ schema: configJsonSchema }).required(), json: jsonSchema({ schema: configJsonSchema }).required(),
@@ -170,6 +170,8 @@ export class ModuleManager {
} }
} }
this.logger.log("booted with", this._booted_with);
this.createModules(initial); this.createModules(initial);
} }
@@ -218,7 +220,8 @@ export class ModuleManager {
private repo() { private repo() {
return this.__em.repo(__bknd, { return this.__em.repo(__bknd, {
silent: !debug_modules, // to prevent exceptions when table doesn't exist
silent: true,
}); });
} }
@@ -271,7 +274,7 @@ export class ModuleManager {
}; };
} }
private async fetch(): Promise<ConfigTable> { private async fetch(): Promise<ConfigTable | undefined> {
this.logger.context("fetch").log("fetching"); this.logger.context("fetch").log("fetching");
const startTime = performance.now(); const startTime = performance.now();
@@ -285,7 +288,7 @@ export class ModuleManager {
if (!result) { if (!result) {
this.logger.log("error fetching").clear(); this.logger.log("error fetching").clear();
throw BkndError.with("no config"); return undefined;
} }
this.logger this.logger
@@ -305,6 +308,7 @@ export class ModuleManager {
try { try {
const state = await this.fetch(); const state = await this.fetch();
if (!state) throw new BkndError("save: no config found");
this.logger.log("fetched version", state.version); this.logger.log("fetched version", state.version);
if (state.version !== version) { if (state.version !== version) {
@@ -321,11 +325,11 @@ export class ModuleManager {
json: configs, json: configs,
}); });
} else { } else {
this.logger.log("version matches"); this.logger.log("version matches", state.version);
// clean configs because of Diff() function // clean configs because of Diff() function
const diffs = diff(state.json, clone(configs)); const diffs = diff(state.json, clone(configs));
this.logger.log("checking diff", diffs); this.logger.log("checking diff", [diffs.length]);
if (diff.length > 0) { if (diff.length > 0) {
// store diff // store diff
@@ -380,78 +384,6 @@ export class ModuleManager {
return this; return this;
} }
private async migrate() {
const state = {
success: false,
migrated: false,
version: {
before: this.version(),
after: this.version(),
},
};
this.logger.context("migrate").log("migrating?", this.version(), CURRENT_VERSION);
if (this.version() < CURRENT_VERSION) {
state.version.before = this.version();
this.logger.log("there are migrations, verify version");
// sync __bknd table
await this.syncConfigTable();
// modules must be built before migration
this.logger.log("building modules");
await this.buildModules({ graceful: true });
this.logger.log("modules built");
try {
const state = await this.fetch();
if (state.version !== this.version()) {
// @todo: potentially drop provided config and use database version
throw new Error(
`Given version (${this.version()}) and fetched version (${state.version}) do not match.`,
);
}
} catch (e: any) {
throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`);
}
this.logger.log("now migrating");
let version = this.version();
let configs: any = this.configs();
//console.log("migrating with", version, configs);
if (Object.keys(configs).length === 0) {
throw new Error("No config to migrate");
}
const [_version, _configs] = await migrate(version, configs, {
db: this.db,
});
version = _version;
configs = _configs;
this._version = version;
state.version.after = version;
state.migrated = true;
this.ctx().flags.sync_required = true;
this.logger.log("setting configs");
this.createModules(configs);
await this.buildModules();
this.logger.log("migrated to", version);
$console.log("Migrated config from", state.version.before, "to", state.version.after);
await this.save();
} else {
this.logger.log("no migrations needed");
}
state.success = true;
this.logger.clear();
return state;
}
private setConfigs(configs: ModuleConfigs): void { private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs"); this.logger.log("setting configs");
objectEach(configs, (config, key) => { objectEach(configs, (config, key) => {
@@ -469,66 +401,66 @@ export class ModuleManager {
async build(opts?: { fetch?: boolean }) { async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version()); this.logger.context("build").log("version", this.version());
this.logger.log("booted with", this._booted_with);
// if no config provided, try fetch from db // if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) { if (this.version() === 0 || opts?.fetch === true) {
if (this.version() === 0) { if (opts?.fetch) {
this.logger.context("no version").log("version is 0"); this.logger.log("force fetch");
} else {
this.logger.context("force fetch").log("force fetch");
} }
try { const result = await this.fetch();
const result = await this.fetch();
// if no version, and nothing found, go with initial
if (!result) {
this.logger.log("nothing in database, go initial");
await this.setupInitial();
} else {
this.logger.log("db has", result.version);
// set version and config from fetched // set version and config from fetched
this._version = result.version; this._version = result.version;
if (this.version() !== CURRENT_VERSION) {
await this.syncConfigTable();
}
if (this.options?.trustFetched === true) { if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)"); this.logger.log("trusting fetched config (mark)");
mark(result.json); mark(result.json);
} }
this.setConfigs(result.json); // if version doesn't match, migrate before building
} catch (e: any) { if (this.version() !== CURRENT_VERSION) {
this.logger.clear(); // fetch couldn't clear this.logger.log("now migrating");
this.logger.context("error handler").log("fetch failed", e.message); await this.syncConfigTable();
// we can safely build modules, since config version is up to date const version_before = this.version();
// it's up to date because we use default configs (no fetch result) const [_version, _configs] = await migrate(version_before, result.json, {
this._version = CURRENT_VERSION; db: this.db,
await this.syncConfigTable(); });
const state = await this.buildModules();
if (!state.saved) { this._version = _version;
await this.save(); this.ctx().flags.sync_required = true;
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
this.createModules(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
this.createModules(result.json);
await this.buildModules();
} }
// run initial setup
await this.setupInitial();
this.logger.clear();
return this;
} }
this.logger.clear();
}
// migrate to latest if needed
this.logger.log("check migrate");
const migration = await this.migrate();
if (migration.success && migration.migrated) {
this.logger.log("skipping build after migration");
} else { } else {
this.logger.log("trigger build modules"); if (this.version() !== CURRENT_VERSION) {
throw new Error(
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
);
}
this.logger.log("current version is up to date", this.version());
await this.buildModules(); await this.buildModules();
} }
this.logger.log("done"); this.logger.log("done");
this.logger.clear();
return this; return this;
} }
@@ -589,6 +521,14 @@ export class ModuleManager {
} }
protected async setupInitial() { protected async setupInitial() {
this.logger.context("initial").log("start");
this._version = CURRENT_VERSION;
await this.syncConfigTable();
const state = await this.buildModules();
if (!state.saved) {
await this.save();
}
const ctx = { const ctx = {
...this.ctx(), ...this.ctx(),
// disable events for initial setup // disable events for initial setup
@@ -601,6 +541,7 @@ export class ModuleManager {
// run first boot event // run first boot event
await this.options?.onFirstBoot?.(); await this.options?.onFirstBoot?.();
this.logger.clear();
} }
mutateConfigSafe<Module extends keyof Modules>( mutateConfigSafe<Module extends keyof Modules>(

View File

@@ -1,6 +1,7 @@
import { _jsonp, transformObject } from "core/utils"; import { _jsonp, transformObject } from "core/utils";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
import { set } from "lodash-es"; import { set } from "lodash-es";
import type { InitialModuleConfigs } from "modules/ModuleManager";
export type MigrationContext = { export type MigrationContext = {
db: Kysely<any>; db: Kysely<any>;
@@ -91,6 +92,17 @@ export const migrations: Migration[] = [
}; };
}, },
}, },
{
// remove admin config
version: 9,
up: async (config) => {
const { admin, ...server } = config.server;
return {
...config,
server,
};
},
},
]; ];
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;

View File

@@ -5,9 +5,9 @@ import { config, isDebug } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { html } from "hono/html"; import { html } from "hono/html";
import { Fragment } from "hono/jsx"; import { Fragment } from "hono/jsx";
import { css, Style } from "hono/css";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { AppTheme } from "modules/server/AppServer";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -73,7 +73,6 @@ export class AdminController extends Controller {
const obj = { const obj = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
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) {
@@ -183,14 +182,13 @@ export class AdminController extends Controller {
assets.css = manifest["src/ui/main.tsx"].css[0] as any; assets.css = manifest["src/ui/main.tsx"].css[0] as any;
} }
const theme = configs.server.admin.color_scheme ?? "light";
const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico"; const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico";
return ( return (
<Fragment> <Fragment>
{/* dnd complains otherwise */} {/* dnd complains otherwise */}
{html`<!DOCTYPE html>`} {html`<!DOCTYPE html>`}
<html lang="en" class={theme}> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
@@ -229,10 +227,9 @@ export class AdminController extends Controller {
</head> </head>
<body> <body>
<div id="root"> <div id="root">
<div id="loading" style={style(theme)}> <Style />
<span style={{ opacity: 0.3, fontSize: 14, fontFamily: "monospace" }}> <div id="loading" className={wrapperStyle}>
Initializing... <span className={loaderStyle}>Initializing...</span>
</span>
</div> </div>
</div> </div>
<script <script
@@ -248,31 +245,27 @@ export class AdminController extends Controller {
} }
} }
const style = (theme: AppTheme) => { const wrapperStyle = css`
const base = { margin: 0;
margin: 0, padding: 0;
padding: 0, height: 100vh;
height: "100vh", width: 100vw;
width: "100vw", display: flex;
display: "flex", justify-content: center;
justifyContent: "center", align-items: center;
alignItems: "center", -webkit-font-smoothing: antialiased;
"-webkit-font-smoothing": "antialiased", -moz-osx-font-smoothing: grayscale;
"-moz-osx-font-smoothing": "grayscale", color: rgb(9,9,11);
}; background-color: rgb(250,250,250);
const styles = {
light: { @media (prefers-color-scheme: dark) {
color: "rgb(9,9,11)", color: rgb(250,250,250);
backgroundColor: "rgb(250,250,250)", background-color: rgb(30,31,34);
}, }
dark: { `;
color: "rgb(250,250,250)",
backgroundColor: "rgb(30,31,34)",
},
};
return { const loaderStyle = css`
...base, opacity: 0.3;
...styles[theme === "light" ? "light" : "dark"], font-size: 14px;
}; font-family: monospace;
}; `;

View File

@@ -1,27 +1,12 @@
import { Exception } from "core"; import { Exception, isDebug } from "core";
import { type Static, StringEnum, Type } from "core/utils"; import { type Static, StringEnum, Type } from "core/utils";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
const appThemes = ["dark", "light", "system"] as const;
export type AppTheme = (typeof appThemes)[number];
export const serverConfigSchema = Type.Object( export const serverConfigSchema = Type.Object(
{ {
admin: Type.Object(
{
basepath: Type.Optional(Type.String({ default: "", pattern: "^(/.+)?$" })),
color_scheme: Type.Optional(StringEnum(["dark", "light", "system"])),
logo_return_path: Type.Optional(
Type.String({
default: "/",
description: "Path to return to after *clicking* the logo",
}),
),
},
{ default: {}, additionalProperties: false },
),
cors: Type.Object( cors: Type.Object(
{ {
origin: Type.String({ default: "*" }), origin: Type.String({ default: "*" }),
@@ -43,12 +28,6 @@ export const serverConfigSchema = Type.Object(
export type AppServerConfig = Static<typeof serverConfigSchema>; export type AppServerConfig = Static<typeof serverConfigSchema>;
/*declare global {
interface Request {
cf: IncomingRequestCfProperties;
}
}*/
export class AppServer extends Module<typeof serverConfigSchema> { export class AppServer extends Module<typeof serverConfigSchema> {
//private admin_html?: string; //private admin_html?: string;
@@ -102,6 +81,12 @@ export class AppServer extends Module<typeof serverConfigSchema> {
return c.json(err.toJSON(), err.code as any); return c.json(err.toJSON(), err.code as any);
} }
if (err instanceof Error) {
if (isDebug()) {
return c.json({ error: err.message, stack: err.stack }, 500);
}
}
return c.json({ error: err.message }, 500); return c.json({ error: err.message }, 500);
}); });
this.setBuilt(); this.setBuilt();

View File

@@ -1,12 +1,10 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd"; import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme"; 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 { ClientProvider, type ClientProviderProps } from "./client"; import { ClientProvider, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
import { BkndModalsProvider } from "./modals"; import { BkndModalsProvider } from "./modals";
@@ -15,7 +13,7 @@ import { Routes } from "./routes";
export type BkndAdminProps = { export type BkndAdminProps = {
baseUrl?: string; baseUrl?: string;
withProvider?: boolean | ClientProviderProps; withProvider?: boolean | ClientProviderProps;
config?: ModuleConfigs["server"]["admin"]; config?: BkndAdminOptions;
}; };
export default function Admin({ export default function Admin({
@@ -24,7 +22,7 @@ export default function Admin({
config, config,
}: BkndAdminProps) { }: BkndAdminProps) {
const Component = ( const Component = (
<BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}> <BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}>
<AdminInternal /> <AdminInternal />
</BkndProvider> </BkndProvider>
); );
@@ -46,7 +44,6 @@ function AdminInternal() {
return ( return (
<MantineProvider {...createMantineTheme(theme as any)}> <MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" /> <Notifications position="top-right" />
<FlashMessage />
<BkndModalsProvider> <BkndModalsProvider>
<Routes /> <Routes />
</BkndModalsProvider> </BkndModalsProvider>

View File

@@ -4,7 +4,13 @@ import { createContext, startTransition, useContext, useEffect, useRef, useState
import { useApi } from "ui/client"; import { useApi } from "ui/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
import type { AppTheme } from "ui/client/use-theme";
export type BkndAdminOptions = {
logo_return_path?: string;
basepath?: string;
theme?: AppTheme;
};
type BkndContext = { type BkndContext = {
version: number; version: number;
schema: ModuleSchemas; schema: ModuleSchemas;
@@ -14,7 +20,7 @@ type BkndContext = {
requireSecrets: () => Promise<void>; requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;
app: AppReduced; app: AppReduced;
adminOverride?: ModuleConfigs["server"]["admin"]; options: BkndAdminOptions;
fallback: boolean; fallback: boolean;
}; };
@@ -29,19 +35,21 @@ enum Fetching {
export function BkndProvider({ export function BkndProvider({
includeSecrets = false, includeSecrets = false,
adminOverride, options,
children, children,
fallback = null, fallback = null,
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick< }: {
BkndContext, includeSecrets?: boolean;
"adminOverride" children: any;
>) { fallback?: React.ReactNode;
options?: BkndAdminOptions;
}) {
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" | "fallback">>(); 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>(false);
const fetching = useRef<Fetching>(Fetching.None); 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();
@@ -93,19 +101,14 @@ export function BkndProvider({
fallback: true, fallback: true,
} as any); } as any);
if (adminOverride) {
newSchema.config.server.admin = {
...newSchema.config.server.admin,
...adminOverride,
};
}
startTransition(() => { startTransition(() => {
setSchema(newSchema); document.startViewTransition(() => {
setWithSecrets(_includeSecrets); setSchema(newSchema);
setFetched(true); setWithSecrets(_includeSecrets);
set_local_version((v) => v + 1); setFetched(true);
fetching.current = Fetching.None; set_local_version((v) => v + 1);
fetching.current = Fetching.None;
});
}); });
} }
@@ -120,13 +123,13 @@ export function BkndProvider({
}, []); }, []);
if (!fetched || !schema) return fallback; if (!fetched || !schema) return fallback;
const app = new AppReduced(schema?.config as any); const app = new AppReduced(schema?.config as any, options);
const actions = getSchemaActions({ api, setSchema, reloadSchema }); const actions = getSchemaActions({ api, setSchema, reloadSchema });
const hasSecrets = withSecrets && !error; const hasSecrets = withSecrets && !error;
return ( return (
<BkndContext.Provider <BkndContext.Provider
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }} value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }}
key={local_version} key={local_version}
> >
{/*{error && ( {/*{error && (
@@ -151,3 +154,12 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo
return ctx; return ctx;
} }
export function useBkndOptions(): BkndAdminOptions {
const ctx = useContext(BkndContext);
return (
ctx.options ?? {
basepath: "/",
}
);
}

View File

@@ -1,6 +1,5 @@
import { Api, type ApiOptions, type TApiUser } from "Api"; import { Api, type ApiOptions, type TApiUser } from "Api";
import { isDebug } from "core"; import { isDebug } from "core";
import type { AppTheme } from "modules/server/AppServer";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ const ClientContext = createContext<{ baseUrl: string; api: Api }>({
@@ -62,7 +61,6 @@ export const useBaseUrl = () => {
type BkndWindowContext = { type BkndWindowContext = {
user?: TApiUser; user?: TApiUser;
logout_route: string; logout_route: string;
color_scheme?: AppTheme;
}; };
export function useBkndWindowContext(): BkndWindowContext { export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) { if (typeof window !== "undefined" && window.__BKND__) {

View File

@@ -1 +1 @@
export { BkndProvider, useBknd } from "./BkndProvider"; export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";

View File

@@ -29,13 +29,3 @@ export function useBkndSystem() {
actions, actions,
}; };
} }
export function useBkndSystemTheme() {
const $sys = useBkndSystem();
return {
theme: $sys.theme,
set: $sys.actions.theme.set,
toggle: () => $sys.actions.theme.toggle(),
};
}

View File

@@ -1,29 +1,49 @@
import type { AppTheme } from "modules/server/AppServer";
import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { create } from "zustand";
import { combine, persist } from "zustand/middleware";
const themes = ["dark", "light", "system"] as const;
export type AppTheme = (typeof themes)[number];
const themeStore = create(
persist(
combine({ theme: null as AppTheme | null }, (set) => ({
setTheme: (theme: AppTheme | any) => {
if (themes.includes(theme)) {
document.startViewTransition(() => {
set({ theme });
});
}
},
})),
{
name: "bknd-admin-theme",
},
),
);
export function useTheme(fallback: AppTheme = "system") { export function useTheme(fallback: AppTheme = "system") {
const b = useBknd(); const b = useBknd();
const winCtx = useBkndWindowContext(); const theme_state = themeStore((state) => state.theme);
const theme_set = themeStore((state) => state.setTheme);
// 1. override // 1. override
// 2. config // 2. local storage
// 3. winCtx // 3. fallback
// 4. fallback // 4. default
// 5. default const override = b?.options?.theme;
const override = b?.adminOverride?.color_scheme;
const config = b?.config.server.admin.color_scheme;
const win = winCtx.color_scheme;
const prefersDark = const prefersDark =
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches; typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = override ?? config ?? win ?? fallback; const theme = override ?? theme_state ?? fallback;
return { return {
theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme, theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme,
value: theme,
themes,
setTheme: theme_set,
state: theme_state,
prefersDark, prefersDark,
override, override,
config,
win,
}; };
} }

View File

@@ -2,6 +2,7 @@ import type { App } from "App";
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data"; import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import { RelationAccessor } from "data/relations/RelationAccessor"; import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows"; import { Flow, TaskMap } from "flows";
import type { BkndAdminOptions } from "ui/client/BkndProvider";
export type AppType = ReturnType<App["toJSON"]>; export type AppType = ReturnType<App["toJSON"]>;
@@ -15,7 +16,10 @@ export class AppReduced {
private _relations: EntityRelation[] = []; private _relations: EntityRelation[] = [];
private _flows: Flow[] = []; private _flows: Flow[] = [];
constructor(protected appJson: AppType) { constructor(
protected appJson: AppType,
protected _options: BkndAdminOptions = {},
) {
//console.log("received appjson", appJson); //console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
@@ -62,18 +66,21 @@ export class AppReduced {
return this.appJson; return this.appJson;
} }
getAdminConfig() { get options() {
return this.appJson.server.admin; return {
basepath: "",
logo_return_path: "/",
...this._options,
};
} }
getSettingsPath(path: string[] = []): string { getSettingsPath(path: string[] = []): string {
const { basepath } = this.getAdminConfig(); const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
const base = `~/${basepath}/settings`.replace(/\/+/g, "/");
return [base, ...path].join("/"); return [base, ...path].join("/");
} }
getAbsolutePath(path?: string): string { getAbsolutePath(path?: string): string {
const { basepath } = this.getAdminConfig(); const { basepath } = this.options;
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/"); return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
} }

View File

@@ -1,28 +0,0 @@
import { useState } from "react";
export type AppTheme = "light" | "dark" | string;
export function useSetTheme(initialTheme: AppTheme = "light") {
const [theme, _setTheme] = useState(initialTheme);
const $html = document.querySelector("#bknd-admin")!;
function setTheme(newTheme: AppTheme) {
$html?.classList.remove("dark", "light");
$html?.classList.add(newTheme);
_setTheme(newTheme);
// @todo: just a quick switcher config update test
fetch("/api/system/config/patch/server/admin", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ color_scheme: newTheme }),
})
.then((res) => res.json())
.then((data) => {
console.log("theme updated", data);
});
}
return { theme, setTheme };
}

View File

@@ -23,7 +23,7 @@ const styles = {
outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80", 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-700 text-red-700 dark:hover:bg-red-900 dark:hover:text-red-200 bg-transparent hover:bg-red-50 link",
}; };
export type BaseProps = { export type BaseProps = {
@@ -51,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 transition-[opacity,background-color,color,border-color]", "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,

View File

@@ -1,25 +1,23 @@
import { import {
Background, Background,
BackgroundVariant, BackgroundVariant,
MarkerType,
MiniMap, MiniMap,
type MiniMapProps, type MiniMapProps,
ReactFlow, ReactFlow,
type ReactFlowProps, type ReactFlowProps,
ReactFlowProvider,
addEdge, addEdge,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
useReactFlow, useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; import { useTheme } from "ui/client/use-theme";
type CanvasProps = ReactFlowProps & { type CanvasProps = ReactFlowProps & {
externalProvider?: boolean; externalProvider?: boolean;
backgroundStyle?: "lines" | "dots"; backgroundStyle?: "lines" | "dots";
minimap?: boolean | MiniMapProps; minimap?: boolean | MiniMapProps;
children?: JSX.Element | ReactNode; children?: Element | ReactNode;
onDropNewNode?: (base: any) => any; onDropNewNode?: (base: any) => any;
onDropNewEdge?: (base: any) => any; onDropNewEdge?: (base: any) => any;
}; };
@@ -38,7 +36,7 @@ export function Canvas({
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []); const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []); const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const { theme } = useBkndSystemTheme(); const { theme } = useTheme();
const [isCommandPressed, setIsCommandPressed] = useState(false); const [isCommandPressed, setIsCommandPressed] = useState(false);
const [isSpacePressed, setIsSpacePressed] = useState(false); const [isSpacePressed, setIsSpacePressed] = useState(false);

View File

@@ -1,8 +1,7 @@
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useBknd } from "ui/client/bknd";
import { json } from "@codemirror/lang-json"; import { json } from "@codemirror/lang-json";
import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid"; import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid";
import { useTheme } from "ui/client/use-theme";
export type CodeEditorProps = ReactCodeMirrorProps & { export type CodeEditorProps = ReactCodeMirrorProps & {
_extensions?: Partial<{ _extensions?: Partial<{
@@ -17,8 +16,7 @@ export default function CodeEditor({
_extensions = {}, _extensions = {},
...props ...props
}: CodeEditorProps) { }: CodeEditorProps) {
const b = useBknd(); const { theme } = useTheme();
const theme = b.app.getAdminConfig().color_scheme;
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
? { ? {
...(typeof basicSetup === "object" ? basicSetup : {}), ...(typeof basicSetup === "object" ? basicSetup : {}),

View File

@@ -74,6 +74,7 @@ export default function ArrayFieldTemplate<
{items.map( {items.map(
({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => { ({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => {
const newChildren = cloneElement(children, { const newChildren = cloneElement(children, {
// @ts-ignore
...children.props, ...children.props,
name: undefined, name: undefined,
title: undefined, title: undefined,

View File

@@ -4,6 +4,7 @@ import {
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
Fragment, Fragment,
type ReactElement, type ReactElement,
type ReactNode,
cloneElement, cloneElement,
useState, useState,
} from "react"; } from "react";
@@ -11,7 +12,7 @@ import { twMerge } from "tailwind-merge";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
export type DropdownItem = export type DropdownItem =
| (() => JSX.Element) | (() => ReactNode)
| { | {
label: string | ReactElement; label: string | ReactElement;
icon?: any; icon?: any;

View File

@@ -7,10 +7,10 @@ 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 { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils"; import { type TSchema, Value } from "core/utils";
import type { Validator } from "json-schema-form-react"; import type { Validator } from "json-schema-form-react";
import { useTheme } from "ui/client/use-theme";
class TypeboxValidator implements Validator<ValueError> { class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) { async validate(schema: TSchema, data: any) {
@@ -46,6 +46,7 @@ export function AuthForm({
buttonLabel = action === "login" ? "Sign in" : "Sign up", buttonLabel = action === "login" ? "Sign in" : "Sign up",
...props ...props
}: LoginFormProps) { }: LoginFormProps) {
const { theme } = useTheme();
const basepath = auth?.basepath ?? "/api/auth"; const basepath = auth?.basepath ?? "/api/auth";
const password = { const password = {
action: `${basepath}/password/${action}`, action: `${basepath}/password/${action}`,

View File

@@ -2,6 +2,7 @@ import type { DB } from "core";
import { import {
type ComponentPropsWithRef, type ComponentPropsWithRef,
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
type ReactNode,
type RefObject, type RefObject,
memo, memo,
useEffect, useEffect,
@@ -27,7 +28,7 @@ export type FileState = {
export type FileStateWithData = FileState & { data: DB["media"] }; export type FileStateWithData = FileState & { data: DB["media"] };
export type DropzoneRenderProps = { export type DropzoneRenderProps = {
wrapperRef: RefObject<HTMLDivElement>; wrapperRef: RefObject<HTMLDivElement | null>;
inputProps: ComponentPropsWithRef<"input">; inputProps: ComponentPropsWithRef<"input">;
state: { state: {
files: FileState[]; files: FileState[];
@@ -59,7 +60,7 @@ export type DropzoneProps = {
show?: boolean; show?: boolean;
text?: string; text?: string;
}; };
children?: (props: DropzoneRenderProps) => JSX.Element; children?: (props: DropzoneRenderProps) => ReactNode;
}; };
function handleUploadError(e: unknown) { function handleUploadError(e: unknown) {
@@ -459,7 +460,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
export type PreviewComponentProps = { export type PreviewComponentProps = {
file: FileState; file: FileState;
fallback?: (props: { file: FileState }) => JSX.Element; fallback?: (props: { file: FileState }) => ReactNode;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
onTouchStart?: () => void; onTouchStart?: () => void;
@@ -486,7 +487,7 @@ type PreviewProps = {
handleUpload: (file: FileState) => Promise<void>; handleUpload: (file: FileState) => Promise<void>;
handleDelete: (file: FileState) => Promise<void>; handleDelete: (file: FileState) => Promise<void>;
}; };
const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) => { const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
const dropdownItems = [ const dropdownItems = [
["initial", "uploaded"].includes(file.state) && { ["initial", "uploaded"].includes(file.state) && {
label: "Delete", label: "Delete",

View File

@@ -48,7 +48,7 @@ export const NavLink = <E extends React.ElementType = "a">({
<Tag <Tag
{...otherProps} {...otherProps}
className={twMerge( className={twMerge(
"px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link", "px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link transition-colors",
disabled && "opacity-50 cursor-not-allowed", disabled && "opacity-50 cursor-not-allowed",
className, className,
)} )}
@@ -80,7 +80,7 @@ export function Main({ children }) {
data-shell="main" data-shell="main"
className={twMerge( className={twMerge(
"flex flex-col flex-grow w-1 flex-shrink-1", "flex flex-col flex-grow w-1 flex-shrink-1",
sidebar.open && "max-w-[calc(100%-350px)]", sidebar.open && "md:max-w-[calc(100%-350px)]",
)} )}
> >
{children} {children}

View File

@@ -1,4 +1,3 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { useMemo } from "react"; import { useMemo } from "react";
import { TbArrowLeft, TbDots } from "react-icons/tb"; import { TbArrowLeft, TbDots } from "react-icons/tb";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
@@ -7,7 +6,7 @@ import { Dropdown } from "../../components/overlay/Dropdown";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
type Breadcrumb = { type Breadcrumb = {
label: string | JSX.Element; label: string | Element;
onClick?: () => void; onClick?: () => void;
href?: string; href?: string;
}; };

View File

@@ -12,7 +12,6 @@ import {
} from "react-icons/tb"; } from "react-icons/tb";
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 { useTheme } from "ui/client/use-theme"; 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";
@@ -24,6 +23,7 @@ import { useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { NavLink } from "./AppShell"; import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils";
export function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
@@ -114,7 +114,7 @@ function SidebarToggler() {
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
const { app } = useBknd(); const { app } = useBknd();
const { theme } = useTheme(); const { theme } = useTheme();
const { logo_return_path = "/" } = app.getAdminConfig(); const { logo_return_path = "/" } = app.options;
return ( return (
<header <header
@@ -142,7 +142,7 @@ export function Header({ hasSidebar = true }) {
} }
function UserMenu() { function UserMenu() {
const { adminOverride, config } = useBknd(); const { config, options } = useBknd();
const auth = useAuth(); const auth = useAuth();
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { logout_route } = useBkndWindowContext(); const { logout_route } = useBkndWindowContext();
@@ -173,7 +173,7 @@ function UserMenu() {
} }
} }
if (!adminOverride) { if (!options.theme) {
items.push(() => <UserMenuThemeToggler />); items.push(() => <UserMenuThemeToggler />);
} }
@@ -193,17 +193,15 @@ function UserMenu() {
} }
function UserMenuThemeToggler() { function UserMenuThemeToggler() {
const { theme, toggle } = useBkndSystemTheme(); const { value, themes, setTheme } = useTheme();
return ( return (
<div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5"> <div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5">
<SegmentedControl <SegmentedControl
withItemsBorders={false}
className="w-full" className="w-full"
data={[ data={themes.map((t) => ({ value: t, label: autoFormatString(t) }))}
{ value: "light", label: "Light" }, value={value}
{ value: "dark", label: "Dark" }, onChange={setTheme}
]}
value={theme}
onChange={toggle}
size="xs" size="xs"
/> />
</div> </div>

View File

@@ -107,8 +107,11 @@ export function createMantineTheme(scheme: "light" | "dark"): {
}), }),
}), }),
Tabs: Tabs.extend({ Tabs: Tabs.extend({
classNames: (theme, props) => ({ vars: (theme, props) => ({
tab: "data-[active=true]:border-primary", // https://mantine.dev/styles/styles-api/
root: {
"--tabs-color": "border-primary",
},
}), }),
}), }),
Menu: Menu.extend({ Menu: Menu.extend({

View File

@@ -49,8 +49,7 @@ export function withQuery(url: string, query: object) {
export function withAbsolute(url: string) { export function withAbsolute(url: string) {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; return app.getAbsolutePath(url);
return `~/${basepath}/${url}`.replace(/\/+/g, "/");
} }
export function useRouteNavigate() { export function useRouteNavigate() {
@@ -65,7 +64,7 @@ export function useNavigate() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const router = useRouter(); const router = useRouter();
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; const basepath = app.options.basepath;
return [ return [
( (
url: string, url: string,
@@ -121,7 +120,6 @@ export function useGoBack(
}, },
) { ) {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath;
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const referrer = document.referrer; const referrer = document.referrer;
const history_length = window.history.length; const history_length = window.history.length;
@@ -142,9 +140,7 @@ export function useGoBack(
} else { } else {
//console.log("used fallback"); //console.log("used fallback");
if (typeof fallback === "string") { if (typeof fallback === "string") {
const _fallback = options?.absolute const _fallback = options?.absolute ? app.getAbsolutePath(fallback) : fallback;
? `~/${basepath}${fallback}`.replace(/\/+/g, "/")
: fallback;
//console.log("fallback", _fallback); //console.log("fallback", _fallback);
if (options?.native) { if (options?.native) {

View File

@@ -1,24 +1,14 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities;
#bknd-admin.dark, @custom-variant dark (&:where(.dark, .dark *));
.dark .bknd-admin,
.bknd-admin.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 */
}
#bknd-admin, #bknd-admin,
.bknd-admin { .bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */ --color-primary: rgb(9 9 11); /* zinc-950 */
--color-background: 250 250 250; /* zinc-50 */ --color-background: rgb(250 250 250); /* zinc-50 */
--color-muted: 228 228 231; /* ? */ --color-muted: rgb(228 228 231); /* ? */
--color-darkest: 0 0 0; /* black */ --color-darkest: rgb(0 0 0); /* black */
--color-lightest: 255 255 255; /* white */ --color-lightest: rgb(255 255 255); /* white */
@mixin light { @mixin light {
--mantine-color-body: rgb(250 250 250); --mantine-color-body: rgb(250 250 250);
@@ -32,6 +22,25 @@
} }
} }
.dark,
.dark .bknd-admin /* currently used for elements, drop after making headless */,
#bknd-admin.dark,
.bknd-admin.dark {
--color-primary: rgb(250 250 250); /* zinc-50 */
--color-background: rgb(30 31 34);
--color-muted: rgb(47 47 52);
--color-darkest: rgb(255 255 255); /* white */
--color-lightest: rgb(24 24 27); /* black */
}
@theme {
--color-primary: var(--color-primary);
--color-background: var(--color-background);
--color-muted: var(--color-muted);
--color-darkest: var(--color-darkest);
--color-lightest: var(--color-lightest);
}
#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;
@@ -51,37 +60,12 @@ body,
@apply flex flex-1 flex-col h-dvh w-dvw; @apply flex flex-1 flex-col h-dvh w-dvw;
} }
@layer components { .link {
.link { @apply active:translate-y-px;
@apply transition-colors active:translate-y-px; }
}
.img-responsive { .img-responsive {
@apply max-h-full w-auto; @apply max-h-full w-auto;
}
/**
* debug classes
*/
.bordered-red {
@apply border-2 border-red-500;
}
.bordered-green {
@apply border-2 border-green-500;
}
.bordered-blue {
@apply border-2 border-blue-500;
}
.bordered-violet {
@apply border-2 border-violet-500;
}
.bordered-yellow {
@apply border-2 border-yellow-500;
}
} }
#bknd-admin, #bknd-admin,

View File

@@ -4,11 +4,19 @@ import Admin from "./Admin";
import "./main.css"; import "./main.css";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render( function render() {
<React.StrictMode> ReactDOM.createRoot(document.getElementById("root")!).render(
<Admin withProvider /> <React.StrictMode>
</React.StrictMode>, <Admin withProvider />
); </React.StrictMode>,
);
}
if ("startViewTransition" in document) {
document.startViewTransition(render);
} else {
render();
}
// REGISTER ERROR OVERLAY // REGISTER ERROR OVERLAY
const showOverlay = true; const showOverlay = true;

View File

@@ -1,4 +1,5 @@
import type { FieldApi, FormApi } from "@tanstack/react-form"; import type { FieldApi, ReactFormExtendedApi } from "@tanstack/react-form";
import type { JSX } from "react";
import { import {
type Entity, type Entity,
type EntityData, type EntityData,
@@ -8,6 +9,7 @@ import {
JsonSchemaField, JsonSchemaField,
RelationField, RelationField,
} from "data"; } from "data";
import { useStore } from "@tanstack/react-store";
import { MediaField } from "media/MediaField"; import { MediaField } from "media/MediaField";
import { type ComponentProps, Suspense } from "react"; import { type ComponentProps, Suspense } from "react";
import { JsonEditor } from "ui/components/code/JsonEditor"; import { JsonEditor } from "ui/components/code/JsonEditor";
@@ -20,13 +22,18 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
// simplify react form types 🤦
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
// biome-ignore format: ...
export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>;
type EntityFormProps = { type EntityFormProps = {
entity: Entity; entity: Entity;
entityId?: number; entityId?: number;
data?: EntityData; data?: EntityData;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
fieldsDisabled: boolean; fieldsDisabled: boolean;
Form: FormApi<any>; Form: FormApi;
className?: string; className?: string;
action: "create" | "update"; action: "create" | "update";
}; };
@@ -42,7 +49,6 @@ export function EntityForm({
action, action,
}: EntityFormProps) { }: EntityFormProps) {
const fields = entity.getFillableFields(action, true); const fields = entity.getFillableFields(action, true);
console.log("data", { data, fields });
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -132,7 +138,7 @@ type EntityFormFieldProps<
T extends keyof JSX.IntrinsicElements = "input", T extends keyof JSX.IntrinsicElements = "input",
F extends Field = Field, F extends Field = Field,
> = ComponentProps<T> & { > = ComponentProps<T> & {
fieldApi: FieldApi<any, any>; fieldApi: TFieldApi;
field: F; field: F;
action: "create" | "update"; action: "create" | "update";
data?: EntityData; data?: EntityData;
@@ -215,7 +221,7 @@ function EntityMediaFormField({
entityId, entityId,
disabled, disabled,
}: { }: {
formApi: FormApi<any>; formApi: FormApi;
field: MediaField; field: MediaField;
entity: Entity; entity: Entity;
entityId?: number; entityId?: number;
@@ -223,7 +229,7 @@ function EntityMediaFormField({
}) { }) {
if (!entityId) return; if (!entityId) return;
const value = formApi.useStore((state) => { const value = useStore(formApi.store, (state) => {
const val = state.values[field.name]; const val = state.values[field.name];
if (!val || typeof val === "undefined") return []; if (!val || typeof val === "undefined") return [];
if (Array.isArray(val)) return val; if (Array.isArray(val)) return val;
@@ -253,7 +259,7 @@ function EntityJsonFormField({
fieldApi, fieldApi,
field, field,
...props ...props
}: { fieldApi: FieldApi<any, any>; field: JsonField }) { }: { fieldApi: TFieldApi; field: JsonField }) {
const handleUpdate = useEvent((value: any) => { const handleUpdate = useEvent((value: any) => {
fieldApi.handleChange(value); fieldApi.handleChange(value);
}); });
@@ -289,7 +295,7 @@ function EntityEnumFormField({
fieldApi, fieldApi,
field, field,
...props ...props
}: { fieldApi: FieldApi<any, any>; field: EnumField }) { }: { fieldApi: TFieldApi; field: EnumField }) {
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
fieldApi.handleChange(e.target.value); fieldApi.handleChange(e.target.value);
}); });

View File

@@ -1,11 +1,11 @@
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react"; import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
import type { AppDataConfig, TAppDataEntity } from "data/data-schema"; import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
import { Canvas } from "ui/components/canvas/Canvas"; import { Canvas } from "ui/components/canvas/Canvas";
import { layoutWithDagre } from "ui/components/canvas/layouts"; import { layoutWithDagre } from "ui/components/canvas/layouts";
import { Panels } from "ui/components/canvas/panels"; import { Panels } from "ui/components/canvas/panels";
import { EntityTableNode } from "./EntityTableNode"; import { EntityTableNode } from "./EntityTableNode";
import { useTheme } from "ui/client/use-theme";
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] { function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
return Object.entries(entities ?? {}).map(([name, entity]) => { return Object.entries(entities ?? {}).map(([name, entity]) => {
@@ -69,7 +69,7 @@ export function DataSchemaCanvas() {
const { const {
config: { data }, config: { data },
} = useBknd(); } = useBknd();
const { theme } = useBkndSystemTheme(); const { theme } = useTheme();
const nodes = entitiesToNodes(data.entities); const nodes = entitiesToNodes(data.entities);
const edges = relationsToEdges(data.relations).map((e) => ({ const edges = relationsToEdges(data.relations).map((e) => ({
...e, ...e,

View File

@@ -1,8 +1,8 @@
import type { FieldApi } from "@tanstack/react-form";
import type { EntityData, JsonSchemaField } from "data"; import type { EntityData, JsonSchemaField } from "data";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy"; import { FieldLabel } from "ui/components/form/Formy";
import { JsonSchemaForm } from "ui/components/form/json-schema"; import { JsonSchemaForm } from "ui/components/form/json-schema";
import type { TFieldApi } from "ui/modules/data/components/EntityForm";
export function EntityJsonSchemaFormField({ export function EntityJsonSchemaFormField({
fieldApi, fieldApi,
@@ -11,7 +11,7 @@ export function EntityJsonSchemaFormField({
disabled, disabled,
...props ...props
}: { }: {
fieldApi: FieldApi<any, any>; fieldApi: TFieldApi;
field: JsonSchemaField; field: JsonSchemaField;
data?: EntityData; data?: EntityData;
disabled?: boolean; disabled?: boolean;

View File

@@ -1,5 +1,4 @@
import { getHotkeyHandler, useHotkeys } from "@mantine/hooks"; import { getHotkeyHandler, useHotkeys } from "@mantine/hooks";
import type { FieldApi } from "@tanstack/react-form";
import { ucFirst } from "core/utils"; import { ucFirst } from "core/utils";
import type { EntityData, RelationField } from "data"; import type { EntityData, RelationField } from "data";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -12,10 +11,11 @@ import { Popover } from "ui/components/overlay/Popover";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { EntityTable, type EntityTableProps } from "../EntityTable"; import type { EntityTableProps } from "../EntityTable";
import type { ResponseObject } from "modules/ModuleApi"; import type { ResponseObject } from "modules/ModuleApi";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2"; import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import type { TFieldApi } from "ui/modules/data/components/EntityForm";
// @todo: allow clear if not required // @todo: allow clear if not required
export function EntityRelationalFormField({ export function EntityRelationalFormField({
@@ -25,7 +25,7 @@ export function EntityRelationalFormField({
disabled, disabled,
tabIndex, tabIndex,
}: { }: {
fieldApi: FieldApi<any, any>; fieldApi: TFieldApi;
field: RelationField; field: RelationField;
data?: EntityData; data?: EntityData;
disabled?: boolean; disabled?: boolean;

View File

@@ -1,5 +1,6 @@
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { TemplateMediaComponent, TemplateMediaMeta } from "./media"; import { TemplateMediaComponent, TemplateMediaMeta } from "./media";
import type { ReactNode } from "react";
export type StepTemplate = { export type StepTemplate = {
id: string; id: string;
@@ -8,8 +9,6 @@ export type StepTemplate = {
Icon: IconType; Icon: IconType;
}; };
const Templates: [() => JSX.Element, StepTemplate][] = [ const Templates: [() => ReactNode, StepTemplate][] = [[TemplateMediaComponent, TemplateMediaMeta]];
[TemplateMediaComponent, TemplateMediaMeta],
];
export default Templates; export default Templates;

View File

@@ -7,7 +7,7 @@ import { Alert } from "ui/components/display/Alert";
* @constructor * @constructor
*/ */
export function FlashMessage() { export function FlashMessage() {
const [flash, setFlash] = useState<any>(); const [flash, setFlash] = useState<ReturnType<typeof getFlashMessage>>();
useEffect(() => { useEffect(() => {
if (!flash) { if (!flash) {

View File

@@ -1,5 +1,5 @@
import { ucFirst } from "core/utils"; import { ucFirst } from "core/utils";
import type { Entity, EntityData, EntityRelation, RepoQuery } from "data"; import type { Entity, EntityData, EntityRelation } from "data";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useApiQuery, useEntityQuery } from "ui/client"; import { useApiQuery, useEntityQuery } from "ui/client";

View File

@@ -70,7 +70,7 @@ export function StepCreate() {
name: "", name: "",
trigger: "manual", trigger: "manual",
mode: "async", mode: "async",
}, } as Static<typeof schema>,
mode: "onSubmit", mode: "onSubmit",
}); });

View File

@@ -20,12 +20,12 @@ import { Dropdown } from "../../components/overlay/Dropdown";
import { useFlow } from "../../container/use-flows"; import { useFlow } from "../../container/use-flows";
import * as AppShell from "../../layouts/AppShell/AppShell"; import * as AppShell from "../../layouts/AppShell/AppShell";
import { SectionHeader } from "../../layouts/AppShell/AppShell"; import { SectionHeader } from "../../layouts/AppShell/AppShell";
import { useTheme } from "ui/client/use-theme";
export function FlowEdit({ params }) { export function FlowEdit({ params }) {
const { app } = useBknd(); const { app } = useBknd();
const { color_scheme: theme } = app.getAdminConfig(); const { theme } = useTheme();
const { basepath } = app.getAdminConfig(); const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const [execution, setExecution] = useState<Execution>(); const [execution, setExecution] = useState<Execution>();
const [selectedNodes, setSelectedNodes] = useState<Node[]>([]); const [selectedNodes, setSelectedNodes] = useState<Node[]>([]);

View File

@@ -1,4 +1,4 @@
import { Suspense, lazy } from "react"; import React, { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
@@ -9,6 +9,7 @@ import FlowRoutes from "./flows";
import MediaRoutes from "./media"; import MediaRoutes from "./media";
import { Root, RootEmpty } from "./root"; import { Root, RootEmpty } from "./root";
import SettingsRoutes from "./settings"; import SettingsRoutes from "./settings";
import { FlashMessage } from "ui/modules/server/FlashMessage";
// @ts-ignore // @ts-ignore
const TestRoutes = lazy(() => import("./test")); const TestRoutes = lazy(() => import("./test"));
@@ -16,11 +17,11 @@ const TestRoutes = lazy(() => import("./test"));
export function Routes() { export function Routes() {
const { app } = useBknd(); const { app } = useBknd();
const { theme } = useTheme(); const { theme } = useTheme();
const { basepath } = app.getAdminConfig();
return ( return (
<div id="bknd-admin" className={theme + " antialiased"}> <div id="bknd-admin" className={theme + " antialiased"}>
<Router base={basepath}> <FlashMessage />
<Router base={app.options.basepath}>
<Switch> <Switch>
<Route path="/auth/login" component={AuthLogin} /> <Route path="/auth/login" component={AuthLogin} />
<Route path="/" nest> <Route path="/" nest>

View File

@@ -15,7 +15,7 @@ import { ServerSettings } from "./routes/server.settings";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
function SettingsSidebar() { function SettingsSidebar() {
const { version, schema, actions } = useBknd(); const { version, schema, actions, app } = useBknd();
useBrowserTitle(["Settings"]); useBrowserTitle(["Settings"]);
async function handleRefresh() { async function handleRefresh() {
@@ -151,11 +151,10 @@ const FallbackRoutes = ({
...settingProps ...settingProps
}: SettingProps<any> & { module: string }) => { }: SettingProps<any> & { module: string }) => {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig(); const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
return ( return (
<Route path={`/${module}`} nest> <Route path={module} nest>
<Switch> <Switch>
<Route <Route
path="/" path="/"

View File

@@ -44,8 +44,7 @@ const uiSchema = {
export const AuthSettings = ({ schema: _unsafe_copy, config }) => { export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
const _s = useBknd(); const _s = useBknd();
const _schema = cloneDeep(_unsafe_copy); const _schema = cloneDeep(_unsafe_copy);
const { basepath } = _s.app.getAdminConfig(); const prefix = _s.app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
try { try {
const user_entity = config.entity_name ?? "users"; const user_entity = config.entity_name ?? "users";

View File

@@ -68,8 +68,7 @@ export const DataSettings = ({
config, config,
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => { }: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
const entities = Object.keys(config.entities ?? {}); const entities = Object.keys(config.entities ?? {});
function fillEntities(schema: any, key: string = "entity") { function fillEntities(schema: any, key: string = "entity") {

View File

@@ -31,8 +31,7 @@ const uiSchema = {
export const FlowsSettings = ({ schema, config }) => { export const FlowsSettings = ({ schema, config }) => {
const { app } = useBknd(); const { app } = useBknd();
const { basepath } = app.getAdminConfig(); const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
function fillTasks(schema: any, flow: any, key: string) { function fillTasks(schema: any, flow: any, key: string) {
const tasks = Object.keys(flow.tasks ?? {}); const tasks = Object.keys(flow.tasks ?? {});

View File

@@ -17,15 +17,11 @@ const uiSchema = {
}; };
export const ServerSettings = ({ schema: _unsafe_copy, config }) => { export const ServerSettings = ({ schema: _unsafe_copy, config }) => {
const { app, adminOverride } = useBknd(); const { app } = useBknd();
const { basepath } = app.getAdminConfig();
const _schema = cloneDeep(_unsafe_copy); const _schema = cloneDeep(_unsafe_copy);
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/"); const prefix = app.getAbsolutePath("settings");
const schema = _schema; const schema = _schema;
if (adminOverride) {
schema.properties.admin.readOnly = true;
}
return ( return (
<Route path="/server" nest> <Route path="/server" nest>
@@ -33,14 +29,6 @@ export const ServerSettings = ({ schema: _unsafe_copy, config }) => {
path="/" path="/"
component={() => ( component={() => (
<Setting <Setting
options={{
showAlert: () => {
if (adminOverride) {
return "The admin settings are read-only as they are overriden. Remaining server configuration can be edited.";
}
return;
},
}}
schema={schema} schema={schema}
uiSchema={uiSchema} uiSchema={uiSchema}
config={config} config={config}

View File

@@ -16,7 +16,6 @@ import ModalTest from "../../routes/test/tests/modal-test";
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform"; import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
import DropdownTest from "./tests/dropdown-test"; 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 FlowsTest from "./tests/flows-test"; import FlowsTest from "./tests/flows-test";
import JsonSchemaForm3 from "./tests/json-schema-form3"; import JsonSchemaForm3 from "./tests/json-schema-form3";
import JsonFormTest from "./tests/jsonform-test"; import JsonFormTest from "./tests/jsonform-test";
@@ -27,9 +26,11 @@ import ReactFlowTest from "./tests/reactflow-test";
import SchemaTest from "./tests/schema-test"; import SchemaTest from "./tests/schema-test";
import SortableTest from "./tests/sortable-test"; import SortableTest from "./tests/sortable-test";
import { SqlAiTest } from "./tests/sql-ai-test"; import { SqlAiTest } from "./tests/sql-ai-test";
import Themes from "./tests/themes";
const tests = { const tests = {
DropdownTest, DropdownTest,
Themes,
ModalTest, ModalTest,
JsonFormTest, JsonFormTest,
FlowFormTest, FlowFormTest,
@@ -42,7 +43,6 @@ const tests = {
SqlAiTest, SqlAiTest,
SortableTest, SortableTest,
ReactHookErrors, ReactHookErrors,
EntityFieldsForm,
FlowsTest, FlowsTest,
AppShellAccordionsTest, AppShellAccordionsTest,
SwaggerTest, SwaggerTest,

View File

@@ -1,296 +0,0 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Select, Switch, Tabs, TextInput, Textarea, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Type } from "@sinclair/typebox";
import { StringEnum, StringIdentifier, transformObject } from "core/utils";
import { FieldClassMap } from "data";
import { omit } from "lodash-es";
import {
type FieldArrayWithId,
type FieldValues,
type UseControllerProps,
type UseFormReturn,
useController,
useFieldArray,
useForm,
} from "react-hook-form";
import { TbChevronDown, TbChevronUp, TbGripVertical, TbTrash } from "react-icons/tb";
import { Button } from "../../../components/buttons/Button";
import { IconButton } from "../../../components/buttons/IconButton";
import { MantineSelect } from "../../../components/form/hook-form-mantine/MantineSelect";
const fieldSchemas = transformObject(omit(FieldClassMap, ["primary"]), (value) => value.schema);
const fieldSchema = Type.Union(
Object.entries(fieldSchemas).map(([type, schema]) =>
Type.Object(
{
type: Type.Const(type),
name: StringIdentifier,
config: Type.Optional(schema),
},
{
additionalProperties: false,
},
),
),
);
const schema = Type.Object({
fields: Type.Array(fieldSchema),
});
const fieldSchema2 = Type.Object({
type: StringEnum(Object.keys(fieldSchemas)),
name: StringIdentifier,
});
function specificFieldSchema(type: keyof typeof fieldSchemas) {
return Type.Omit(fieldSchemas[type], [
"label",
"description",
"required",
"fillable",
"hidden",
"virtual",
]);
}
export default function EntityFieldsForm() {
const {
control,
formState: { isValid, errors },
getValues,
handleSubmit,
watch,
register,
setValue,
} = useForm({
mode: "onTouched",
resolver: typeboxResolver(schema),
defaultValues: {
fields: [{ type: "text", name: "", config: {} }],
sort: { by: "-1", dir: "asc" },
},
});
const defaultType = Object.keys(fieldSchemas)[0];
const { fields, append, prepend, remove, swap, move, insert, update } = useFieldArray({
control, // control props comes from useForm (optional: if you are using FormProvider)
name: "fields", // unique name for your Field Array
});
function handleAppend() {
append({ type: "text", name: "", config: {} });
}
return (
<div className="flex flex-col gap-1 p-8">
{/*{fields.map((field, index) => (
<EntityField
key={field.id}
field={field}
index={index}
form={{ watch, register, setValue, getValues, control }}
defaultType={defaultType}
remove={remove}
/>
))}*/}
{fields.map((field, index) => (
<EntityFieldForm key={field.id} value={field} index={index} update={update} />
))}
<Button className="justify-center" onClick={handleAppend}>
Add Field
</Button>
<div>
<pre>{JSON.stringify(watch(), null, 2)}</pre>
</div>
</div>
);
}
function EntityFieldForm({ update, index, value }) {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm({
mode: "onBlur",
resolver: typeboxResolver(
Type.Object({
type: StringEnum(Object.keys(fieldSchemas)),
name: Type.String({ minLength: 1, maxLength: 3 }),
}),
),
defaultValues: value,
});
function handleUpdate({ id, ...data }) {
console.log("data", data);
update(index, data);
}
return (
<form>
<MantineSelect
control={control}
name="type"
data={[...Object.keys(fieldSchemas), "test"]}
/>
<TextInput
label="Name"
placeholder="name"
classNames={{ root: "w-full" }}
{...register("name")}
error={errors.name?.message as any}
/>
</form>
);
}
function EntityFieldController({
name,
control,
defaultValue,
rules,
shouldUnregister,
}: UseControllerProps & {
index: number;
}) {
const {
field: { value, onChange: fieldOnChange, ...field },
fieldState,
} = useController({
name,
control,
defaultValue,
rules,
shouldUnregister,
});
return <div>field</div>;
}
function EntityField({
field,
index,
form: { watch, register, setValue, getValues, control },
remove,
defaultType,
}: {
field: FieldArrayWithId;
index: number;
form: Pick<UseFormReturn<any>, "watch" | "register" | "setValue" | "getValues" | "control">;
remove: (index: number) => void;
defaultType: string;
}) {
const [opened, handlers] = useDisclosure(false);
const prefix = `fields.${index}` as const;
const name = watch(`${prefix}.name`);
const enabled = name?.length > 0;
const type = watch(`${prefix}.type`);
//const config = watch(`${prefix}.config`);
const selectFieldRegister = register(`${prefix}.type`);
//console.log("type", type, specificFieldSchema(type as any));
function handleDelete(index: number) {
return () => {
if (name.length === 0) {
remove(index);
return;
}
window.confirm(`Sure to delete "${name}"?`) && remove(index);
};
}
return (
<div key={field.id} className="flex flex-col border border-muted rounded">
<div className="flex flex-row gap-2 px-2 pt-1 pb-2">
<div className="flex items-center">
<IconButton Icon={TbGripVertical} className="mt-1" />
</div>
<div className="flex flex-row flex-grow gap-1">
<div>
<Select
label="Type"
data={[...Object.keys(fieldSchemas), "test"]}
defaultValue={defaultType}
onBlur={selectFieldRegister.onBlur}
onChange={(value) => {
setValue(`${prefix}.type`, value as any);
setValue(`${prefix}.config`, {} as any);
}}
/>
</div>
<TextInput
label="Name"
placeholder="name"
classNames={{ root: "w-full" }}
{...register(`fields.${index}.name`)}
/>
</div>
<div className="flex items-end ">
<div className="flex flex-row gap-1">
<Tooltip label="Specify a property name to see options." disabled={enabled}>
<Button
IconRight={opened ? TbChevronUp : TbChevronDown}
onClick={handlers.toggle}
variant={opened ? "default" : "ghost"}
disabled={!enabled}
>
Options
</Button>
</Tooltip>
<IconButton Icon={TbTrash} onClick={handleDelete(index)} />
</div>
</div>
</div>
{/*{enabled && opened && (
<div className="flex flex-col border-t border-t-muted px-3 py-2">
<Tabs defaultValue="general">
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="specific">Specific</Tabs.Tab>
<Tabs.Tab value="visibility">Visiblity</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<div className="flex flex-col gap-2 pt-3 pb-1" key={`${prefix}_${type}`}>
<Switch
label="Required"
{...register(`${prefix}.config.required` as any)}
/>
<TextInput
label="Label"
placeholder="Label"
{...register(`${prefix}.config.label` as any)}
/>
<Textarea
label="Description"
placeholder="Description"
{...register(`${prefix}.config.description` as any)}
/>
<Switch label="Virtual" {...register(`${prefix}.config.virtual` as any)} />
</div>
</Tabs.Panel>
<Tabs.Panel value="specific">
<div className="flex flex-col gap-2 pt-3 pb-1">
<JsonSchemaForm
key={type}
schema={specificFieldSchema(type as any)}
uiSchema={dataFieldsUiSchema.config}
className="legacy hide-required-mark fieldset-alternative mute-root"
onChange={(value) => {
setValue(`${prefix}.config`, {
...getValues([`fields.${index}.config`])[0],
...value
});
}}
/>
</div>
</Tabs.Panel>
</Tabs>
</div>
)}*/}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import * as Formy from "ui/components/form/Formy";
export default function Themes() {
return (
<div className="flex flex-col p-3 gap-4">
<div className="flex flex-row gap-2 items-center">
<Button size="small">Small</Button>
<Button>Default</Button>
<Button variant="primary">Primary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="outline">Outline</Button>
<Button variant="red">Red</Button>
<Button variant="subtlered">Subtlered</Button>
<Button size="large">Large</Button>
</div>
<div className="flex flex-row gap-1">
<Alert.Exception title="Exception">Alert.Exception</Alert.Exception>
<Alert.Info title="Info">Alert.Info</Alert.Info>
<Alert.Success title="Success">Alert.Success</Alert.Success>
<Alert.Warning title="Warning">Alert.Warning</Alert.Warning>
</div>
<div className="flex flex-row gap-3 items-start">
<Formy.Group>
<Formy.Label>Input</Formy.Label>
<Formy.Input placeholder="Input" />
</Formy.Group>
<Formy.Group>
<Formy.Label>Checkbox</Formy.Label>
<Formy.BooleanInput />
</Formy.Group>
<Formy.Group>
<Formy.Label>Switch</Formy.Label>
<Formy.Switch />
</Formy.Group>
<Formy.Group>
<Formy.Label>Select</Formy.Label>
<Formy.Select>
<option value="" />
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</Formy.Select>
</Formy.Group>
</div>
</div>
);
}

View File

@@ -88,7 +88,7 @@ input[disabled]::placeholder {
} }
.cm-editor { .cm-editor {
background: transparent; /*background: transparent;*/
} }
.cm-editor.cm-focused { .cm-editor.cm-focused {
outline: none; outline: none;

View File

@@ -3,6 +3,7 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import { devServerConfig } from "./src/adapter/vite/dev-server-config"; import { devServerConfig } from "./src/adapter/vite/dev-server-config";
import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -25,6 +26,7 @@ export default defineConfig({
...devServerConfig, ...devServerConfig,
entry: "./vite.dev.ts", entry: "./vite.dev.ts",
}), }),
tailwindcss(),
], ],
build: { build: {
manifest: true, manifest: true,

View File

@@ -3,6 +3,8 @@ import { serveStatic } from "@hono/node-server/serve-static";
import { showRoutes } from "hono/dev"; import { showRoutes } from "hono/dev";
import { App, registries } from "./src"; import { App, registries } from "./src";
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
import { EntityManager, LibsqlConnection } from "data";
import { __bknd } from "modules/ModuleManager";
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
@@ -21,10 +23,30 @@ const credentials = example
url: ":memory:", url: ":memory:",
}; };
let initialConfig: any = undefined;
if (example) { if (example) {
const { version, ...config } = JSON.parse(await readFile(`.configs/${example}.json`, "utf-8")); const { version, ...config } = JSON.parse(await readFile(`.configs/${example}.json`, "utf-8"));
initialConfig = config;
// create db with config
const conn = new LibsqlConnection(credentials);
const em = new EntityManager([__bknd], conn);
try {
await em.schema().sync({ force: true });
} catch (e) {}
const { data: existing } = await em.repo(__bknd).findOne({ type: "config" });
if (!existing || existing.version !== version) {
if (existing) await em.mutator(__bknd).deleteOne(existing.id);
await em.mutator(__bknd).insertOne({
version,
json: config,
created_at: new Date(),
type: "config",
});
} else {
await em.mutator(__bknd).updateOne(existing.id, {
json: config,
});
}
} }
let app: App; let app: App;
@@ -35,7 +57,6 @@ export default {
if (!app || recreate) { if (!app || recreate) {
app = App.create({ app = App.create({
connection: credentials, connection: credentials,
initialConfig,
}); });
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,

View File

@@ -8,8 +8,7 @@
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space"
"ignore": ["**/package.json"]
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {
@@ -23,6 +22,13 @@
"indentWidth": 3 "indentWidth": 3
} }
}, },
"json": {
"formatter": {
"indentWidth": 2,
"lineWidth": 80,
"indentStyle": "space"
}
},
"files": { "files": {
"ignore": [ "ignore": [
"**/node_modules/**", "**/node_modules/**",

1823
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ FROM node:20 as builder
WORKDIR /app WORKDIR /app
# Install & copy required cli # Install & copy required cli
RUN npm install --omit=dev bknd RUN npm install --omit=dev bknd@0.9.1
RUN mkdir /output && cp -r node_modules/bknd/dist /output/dist RUN mkdir /output && cp -r node_modules/bknd/dist /output/dist
# Stage 2: Final minimal image # Stage 2: Final minimal image
@@ -19,10 +19,10 @@ RUN npm install @libsql/client
# Create volume and init args # Create volume and init args
VOLUME /data VOLUME /data
ENV DEFAULT_ARGS "--db-url file:/data/data.db" ENV DEFAULT_ARGS="--db-url file:/data/data.db"
# Copy output from builder # Copy output from builder
COPY --from=builder /output/dist ./dist COPY --from=builder /output/dist ./dist
EXPOSE 1337 EXPOSE 1337
CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}}"] CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}} --no-open"]

View File

@@ -14,7 +14,7 @@ export const prerender = false;
<body> <body>
<Admin <Admin
withProvider={{ user }} withProvider={{ user }}
config={{ basepath: "/admin", color_scheme: "dark", logo_return_path: "/../" }} config={{ basepath: "/admin", theme: "dark", logo_return_path: "/../" }}
client:only client:only
/> />
</body> </body>

View File

@@ -1,6 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import type { ReactNode } from "react"; import { Footer } from "@/components/Footer";
import { Footer } from "./Footer";
export default async function Layout({ export default async function Layout({
children, children,
@@ -36,41 +35,3 @@ export default async function Layout({
</div> </div>
); );
} }
export const Buttons = () => (
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://docs.bknd.io/integration/nextjs"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
);
export const List = ({ items = [] }: { items: ReactNode[] }) => (
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
{items.map((item, i) => (
<li key={i} className={i < items.length - 1 ? "mb-2" : ""}>
{item}
</li>
))}
</ol>
);

View File

@@ -1,7 +1,7 @@
import { getApi } from "@/bknd"; import { getApi } from "@/bknd";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { Fragment } from "react"; import { Fragment } from "react";
import { List } from "@/app/(main)/layout"; import { List } from "@/components/List";
export default async function Home() { export default async function Home() {
// without "{ verify: true }", this page can be static // without "{ verify: true }", this page can be static

View File

@@ -1,5 +1,7 @@
import { getApi } from "@/bknd"; import { getApi } from "@/bknd";
import { Buttons, List } from "../layout"; import { Buttons } from "@/components/Buttons";
import { List } from "@/components/List";
import Link from "next/link";
export default async function SSRPage() { export default async function SSRPage() {
const api = await getApi({ verify: true }); const api = await getApi({ verify: true });
@@ -11,21 +13,21 @@ export default async function SSRPage() {
<List items={data.map((todo) => todo.title)} /> <List items={data.map((todo) => todo.title)} />
<Buttons /> <Buttons />
<p> <div>
{user ? ( {user ? (
<> <>
Logged in as {user.email}.{" "} Logged in as {user.email}.{" "}
<a className="font-medium underline" href="/api/auth/logout"> <Link className="font-medium underline" href="/api/auth/logout">
Logout Logout
</a> </Link>
</> </>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p> <p>
Not logged in.{" "} Not logged in.{" "}
<a className="font-medium underline" href="/admin/auth/login"> <Link className="font-medium underline" href="/admin/auth/login">
Login Login
</a> </Link>
</p> </p>
<p className="text-xs opacity-50"> <p className="text-xs opacity-50">
Sign in with:{" "} Sign in with:{" "}
@@ -39,7 +41,7 @@ export default async function SSRPage() {
</p> </p>
</div> </div>
)} )}
</p> </div>
</> </>
); );
} }

View File

@@ -11,7 +11,7 @@ export default async function AdminPage() {
config={{ config={{
basepath: "/admin", basepath: "/admin",
logo_return_path: "/../", logo_return_path: "/../",
color_scheme: "system", theme: "system",
}} }}
/> />
); );

View File

@@ -0,0 +1,29 @@
import Image from "next/image";
export const Buttons = () => (
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://docs.bknd.io/integration/nextjs"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
);

View File

@@ -2,27 +2,28 @@
import Image from "next/image"; import Image from "next/image";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link";
export function Footer() { export function Footer() {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center"> <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a <Link
className="flex items-center gap-2 hover:underline hover:underline-offset-4" className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href={pathname === "/" ? "/ssr" : "/"} href={pathname === "/" ? "/ssr" : "/"}
> >
<Image aria-hidden src="/file.svg" alt="File icon" width={16} height={16} /> <Image aria-hidden src="/file.svg" alt="File icon" width={16} height={16} />
{pathname === "/" ? "SSR" : "Home"} {pathname === "/" ? "SSR" : "Home"}
</a> </Link>
<a <Link
className="flex items-center gap-2 hover:underline hover:underline-offset-4" className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="/admin" href="/admin"
> >
<Image aria-hidden src="/window.svg" alt="Window icon" width={16} height={16} /> <Image aria-hidden src="/window.svg" alt="Window icon" width={16} height={16} />
Admin Admin
</a> </Link>
<a <Link
className="flex items-center gap-2 hover:underline hover:underline-offset-4" className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://bknd.io" href="https://bknd.io"
target="_blank" target="_blank"
@@ -30,7 +31,7 @@ export function Footer() {
> >
<Image aria-hidden src="/globe.svg" alt="Globe icon" width={16} height={16} /> <Image aria-hidden src="/globe.svg" alt="Globe icon" width={16} height={16} />
Go to bknd.io Go to bknd.io
</a> </Link>
</footer> </footer>
); );
} }

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
export const List = ({ items = [] }: { items: ReactNode[] }) => (
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
{items.map((item, i) => (
<li key={i} className={i < items.length - 1 ? "mb-2" : ""}>
{item}
</li>
))}
</ol>
);

View File

@@ -5,6 +5,6 @@ export default adminPage({
config: { config: {
basepath: "/admin", basepath: "/admin",
logo_return_path: "/../", logo_return_path: "/../",
color_scheme: "system" theme: "system",
} },
}); });