Merge pull request #57 from bknd-io/feat/elements-docs-and-custom

restructured elements for better customization
This commit is contained in:
dswbx
2025-01-25 09:17:39 +01:00
committed by GitHub
10 changed files with 211 additions and 40 deletions

View File

@@ -3,7 +3,13 @@
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
bknd simplifies app development by providing fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
<p align="center" width="100%">
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema">
<strong>⭐ Live Demo</strong>
</a>
</p>
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
**For documentation and examples, please visit https://docs.bknd.io.**
@@ -17,7 +23,7 @@ bknd simplifies app development by providing fully functional backend for databa
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/ui/index.js?compression=gzip&label=bknd/ui)
The unpacked size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
## Motivation
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
@@ -43,8 +49,8 @@ The package is mainly split into 4 parts, each serving a specific purpose:
| Import | Purpose |
|-----------------------------|------------------------------------------------------|
| `bknd`<br/>`bknd/adapter/*` | Backend including all APIs |
| `bknd/ui` | Admin UI components for react frameworks at |
| `bknd`<br/>`bknd/adapter/*` | Backend including APIs and adapters |
| `bknd/ui` | Admin UI components for react frameworks |
| `bknd/client` | TypeScript SDK and React hooks for the API endpoints |
| `bknd/elements` | React components for authentication and media |
@@ -110,6 +116,7 @@ export default function App() {
You don't have to figure out API details to include media uploads to your app. For an user avatar upload, this is all you need:
```tsx
import { Media } from "bknd/elements"
import "bknd/dist/main.css"
export function UserAvatar() {
return <Media.Dropzone

View File

@@ -14,3 +14,6 @@ export { registries } from "modules/registries";
export type * from "./adapter";
export { Api, type ApiOptions } from "./Api";
export type { MediaFieldSchema } from "media/AppMedia";
export type { UserFieldSchema } from "auth/AppAuth";

View File

@@ -7,10 +7,23 @@ export type AuthScreenProps = {
action?: "login" | "register";
logo?: ReactNode;
intro?: ReactNode;
formOnly?: boolean;
};
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
export function AuthScreen({
method = "POST",
action = "login",
logo,
intro,
formOnly
}: AuthScreenProps) {
const { strategies, basepath, loading } = useAuthStrategies();
const Form = <AuthForm auth={{ basepath, strategies }} method={method} action={action} />;
if (formOnly) {
if (loading) return null;
return Form;
}
return (
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
@@ -25,7 +38,7 @@ export function AuthScreen({ method = "POST", action = "login", logo, intro }: A
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
)}
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
{Form}
</div>
)}
</div>

View File

@@ -1,3 +1,4 @@
import { useAuthStrategies } from "../hooks/use-auth";
import { AuthForm } from "./AuthForm";
import { AuthScreen } from "./AuthScreen";
import { SocialLink } from "./SocialLink";
@@ -7,3 +8,5 @@ export const Auth = {
Form: AuthForm,
SocialLink: SocialLink
};
export { useAuthStrategies };

View File

@@ -1,2 +1,2 @@
export { Auth } from "./auth";
export * from "./auth";
export * from "./media";

View File

@@ -1,31 +1,32 @@
import type { RepoQuery, RepoQueryIn } from "data";
import type { RepoQueryIn } from "data";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId } from "react";
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
import { type ReactNode, createContext, useContext, useId } from "react";
import { useApi, useEntityQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper";
export type DropzoneContainerProps = {
children?: (props: DropzoneRenderProps) => JSX.Element;
children?: ReactNode;
initialItems?: MediaFieldSchema[];
entity?: {
name: string;
id: number;
field: string;
};
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
query?: RepoQueryIn;
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
Partial<DropzoneProps>;
} & Omit<Partial<DropzoneProps>, "children" | "initialItems">;
const DropzoneContainerContext = createContext<DropzoneRenderProps>(undefined!);
export function DropzoneContainer({
initialItems,
basepath = "/api/media",
storage = {},
entity_name = "media",
media,
entity,
query,
children,
...props
}: DropzoneContainerProps) {
const id = useId();
@@ -33,10 +34,11 @@ export function DropzoneContainer({
const baseUrl = api.baseUrl;
const invalidate = useInvalidate();
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
console.log("dropzone:baseUrl", baseUrl);
const entity_name = (media?.entity_name ?? "media") as "media";
//console.log("dropzone:baseUrl", baseUrl);
const $q = useEntityQuery(
entity_name as "media",
entity_name,
undefined,
{
...query,
@@ -89,6 +91,18 @@ export function DropzoneContainer({
autoUpload
initialItems={_initialItems}
{...props}
/>
>
{children
? (props) => (
<DropzoneContainerContext.Provider value={props}>
{children}
</DropzoneContainerContext.Provider>
)
: undefined}
</Dropzone>
);
}
export function useDropzone() {
return useContext(DropzoneContainerContext);
}

View File

@@ -1,11 +1,14 @@
import { PreviewWrapperMemoized } from "./Dropzone";
import { DropzoneContainer } from "./DropzoneContainer";
import { DropzoneContainer, useDropzone } from "./DropzoneContainer";
export const Media = {
Dropzone: DropzoneContainer,
Preview: PreviewWrapperMemoized
Preview: PreviewWrapperMemoized,
useDropzone: useDropzone
};
export { useDropzone as useMediaDropzone };
export type {
PreviewComponentProps,
FileState,

View File

@@ -1,4 +1,4 @@
import { type DropzoneRenderProps, Media } from "ui/elements";
import { Media } from "ui/elements";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
export default function DropzoneElementTest() {
@@ -12,7 +12,7 @@ export default function DropzoneElementTest() {
maxItems={1}
overwrite
>
{(props) => <CustomUserAvatarDropzone {...props} />}
<CustomUserAvatarDropzone />
</Media.Dropzone>
</div>
@@ -49,12 +49,13 @@ export default function DropzoneElementTest() {
);
}
function CustomUserAvatarDropzone({
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { openFileInput }
}: DropzoneRenderProps) {
function CustomUserAvatarDropzone() {
const {
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { openFileInput }
} = Media.useDropzone();
const file = files[0];
return (

View File

@@ -3,23 +3,134 @@ title: "React Elements"
description: "Speed up your frontend development"
---
<Note>
The documentation is currently a work in progress and not complete.
</Note>
Not only creating and maintaing a backend is time-consuming, but also integrating it into your frontend can be a hassle. With `bknd/elements`, you can easily add media uploads and authentication forms to your app without having to figure out API details.
## Media uploads
<Note>
In order to use these exported elements, make sure to wrap your app inside `ClientProvider`. See the [React Setup](/usage/react#setup) for more information.
</Note>
# Media
## Media.Dropzone
The `Media.Dropzone` element allows retrieving from and uploading media items to your bknd instance. Without any properties specified, it will behave similar to your media library inside the bknd Admin UI. Here is how to get the last 10 items:
```tsx
import { Media } from "bknd/elements"
export function UserAvatar() {
export default function MediaGallery() {
return <Media.Dropzone query={{ limit: 10, sort: "-id" }} />
}
```
Since you can also upload media to a specific entity, you can also point that `Dropzone` to it. Here is an example of a single user avatar that gets overwritten on re-upload:
```tsx
import { Media } from "bknd/elements";
export default function UserAvatar() {
return <Media.Dropzone
entity={{ name: "users", id: 1, field: "avatar" }}
maxItems={1}
overwrite
entity={{ name: "users", id: 1, field: "avatar" }}
maxItems={1}
overwrite
/>
}
```
### Props
- `initialItems?: xMediaFieldSchema[]`: Initial items to display, must be an array of media objects.
- `entity?: { name: string; id: number; field: string }`: If given, the initial media items fetched will be from this entity.
- `query?: RepoQueryIn`: Query to filter the media items.
- `overwrite?: boolean`: If true, the media item will be overwritten on entity media uploads if limit was reached.
- `maxItems?: number`: Maximum number of media items that can be uploaded.
- `autoUpload?: boolean`: If true, the media items will be uploaded automatically.
- `onRejected?: (files: FileWithPath[]) => void`: Callback when a file is rejected.
- `onDeleted?: (file: FileState) => void`: Callback when a file is deleted.
- `onUploaded?: (file: FileState) => void`: Callback when a file is uploaded.
- `placeholder?: { show?: boolean; text?: string }`: Placeholder text to show when no media items are present.
### Customize Rendering
You can also customize the rendering of the media items and its uploading by passing a react element as a child. Here is an example of a custom `Media.Dropzone` that renders an user avatar (styled using tailwind):
```tsx
import { Media, useMediaDropzone } from "bknd/elements";
export default function CustomUserAvatar() {
return <Media.Dropzone
entity={{ name: "users", id: 1, field: "avatar" }}
maxItems={1}
overwrite
>
<CustomUserAvatar />
</Media.Dropzone>
}
function CustomUserAvatar() {
const {
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { openFileInput }
} = useMediaDropzone();
const file = files[0];
return (
<div
ref={wrapperRef}
className="size-32 rounded-full border border-gray-200 flex justify-center items-center leading-none overflow-hidden"
>
<div className="hidden">
<input {...inputProps} />
</div>
{showPlaceholder && <>{isOver && isOverAccepted ? "let it drop" : "drop here"}</>}
{file && (
<Media.Preview
file={file}
className="object-cover w-full h-full"
onClick={openFileInput}
/>
)}
</div>
);
}
```
# Auth
Adding authentication to your app with bknd is as easy as adding a `<form method="POST" />` with an action pointing to the action (`login` or `register`) to the strategy you want to use, e.g. for the password strategy, use `/api/auth/password/login`. But to make it even easier, you can use the `Auth.*` elements.
## `Auth.Screen`
The `Auth.Screen` element is a wrapper around the `Auth.Form` element that provides a full page screen. The current layout is admittedly very basic, but there will be more customization options in the future.
```tsx
import { Auth } from "bknd/elements"
export default function LoginScreen() {
return <Auth.Screen action="login" />
}
```
### Props
Note that this component doesn't require any strategy-specific information, as it gathers it itself.
- `action: "login" | "register"`: The action to perform.
- `method?: "POST" | "GET"`: The method to use for the form.
## `Auth.Form`
If you only wish to render the form itself without the screen, you can use the `Auth.Form` element. Unlike the `Auth.Screen`, this element requires the `strategy` prop to be set to the strategy you want to use. You can either specify it manually, use use the exported hook `useAuthStrategies()` for fetch them from your bknd instance.
```tsx
import { Auth, useAuthStrategies } from "bknd/elements"
export default function LoginForm() {
const { strategies, basepath, loading } = useAuthStrategies();
if (loading) return null;
return <Auth.Form
action="login"
strategies={strategies}
basepath={basepath}
/>
}
```

View File

@@ -3,7 +3,7 @@ title: 'SDK (React)'
description: 'Use the bknd SDK for React'
---
bknd exports 4 useful hooks to work with your backend:
There are 4 useful hooks to work with your backend:
1. simple hooks which are solely based on the [API](/usage/sdk):
- [`useApi`](#useapi)
- [`useEntity`](#useentity)
@@ -11,6 +11,22 @@ bknd exports 4 useful hooks to work with your backend:
- [`useApiQuery`](#useapiquery)
- [`useEntityQuery`](#useentityquery)
## Setup
In order to use them, make sure you wrap your `<App />` inside `<ClientProvider />`, so that these hooks point to your bknd instance:
```tsx
import { ClientProvider } from "bknd/client";
export default function App() {
return <ClientProvider>
{/* your app */}
</ClientProvider>
}
```
For all other examples below, we'll assume that your app is wrapped inside the `ClientProvider`.
## `useApi()`
To use the simple hook that returns the Api, you can use:
```tsx