mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge remote-tracking branch 'origin/feat/auth-components' into release/0.5
This commit is contained in:
@@ -138,7 +138,12 @@ await tsup.build({
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
|
entry: [
|
||||||
|
"src/ui/index.ts",
|
||||||
|
"src/ui/client/index.ts",
|
||||||
|
"src/ui/elements/index.ts",
|
||||||
|
"src/ui/main.css"
|
||||||
|
],
|
||||||
outDir: "dist/ui",
|
outDir: "dist/ui",
|
||||||
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
|
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"liquidjs": "^10.15.0",
|
"liquidjs": "^10.15.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"swr": "^2.2.5"
|
"swr": "^2.2.5",
|
||||||
|
"json-schema-form-react": "link:json-schema-form-react"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
@@ -103,6 +104,11 @@
|
|||||||
"import": "./dist/ui/index.js",
|
"import": "./dist/ui/index.js",
|
||||||
"require": "./dist/ui/index.cjs"
|
"require": "./dist/ui/index.cjs"
|
||||||
},
|
},
|
||||||
|
"./elements": {
|
||||||
|
"types": "./dist/types/ui/elements/index.d.ts",
|
||||||
|
"import": "./dist/ui/elements/index.js",
|
||||||
|
"require": "./dist/ui/elements/index.cjs"
|
||||||
|
},
|
||||||
"./client": {
|
"./client": {
|
||||||
"types": "./dist/types/ui/client/index.d.ts",
|
"types": "./dist/types/ui/client/index.d.ts",
|
||||||
"import": "./dist/ui/client/index.js",
|
"import": "./dist/ui/client/index.js",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
|||||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||||
|
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||||
|
|
||||||
const guardConfigSchema = Type.Object({
|
const guardConfigSchema = Type.Object({
|
||||||
enabled: Type.Optional(Type.Boolean({ default: false }))
|
enabled: Type.Optional(Type.Boolean({ default: false }))
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { tbValidator as tb } from "core";
|
|||||||
import { Type } from "core/utils";
|
import { Type } from "core/utils";
|
||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import type { StorageAdapter } from "media";
|
import type { StorageAdapter } from "media";
|
||||||
import { StorageEvents } from "media";
|
import { StorageEvents, getRandomizedFilename } from "media";
|
||||||
import { getRandomizedFilename } from "media";
|
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import type { AppMedia } from "../AppMedia";
|
import type { AppMedia } from "../AppMedia";
|
||||||
import { MediaField } from "../MediaField";
|
import { MediaField } from "../MediaField";
|
||||||
@@ -109,7 +108,7 @@ export class MediaController extends Controller {
|
|||||||
return c.json({ error: `Invalid field "${field_name}"` }, 400);
|
return c.json({ error: `Invalid field "${field_name}"` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaEntity = this.media.getMediaEntity();
|
const media_entity = this.media.getMediaEntity().name as "media";
|
||||||
const reference = `${entity_name}.${field_name}`;
|
const reference = `${entity_name}.${field_name}`;
|
||||||
const mediaRef = {
|
const mediaRef = {
|
||||||
scope: field_name,
|
scope: field_name,
|
||||||
@@ -119,11 +118,10 @@ export class MediaController extends Controller {
|
|||||||
|
|
||||||
// check max items
|
// check max items
|
||||||
const max_items = field.getMaxItems();
|
const max_items = field.getMaxItems();
|
||||||
const ids_to_delete: number[] = [];
|
const paths_to_delete: string[] = [];
|
||||||
const id_field = mediaEntity.getPrimaryField().name;
|
|
||||||
if (max_items) {
|
if (max_items) {
|
||||||
const { overwrite } = c.req.valid("query");
|
const { overwrite } = c.req.valid("query");
|
||||||
const { count } = await this.media.em.repository(mediaEntity).count(mediaRef);
|
const { count } = await this.media.em.repository(media_entity).count(mediaRef);
|
||||||
|
|
||||||
// if there are more than or equal to max items
|
// if there are more than or equal to max items
|
||||||
if (count >= max_items) {
|
if (count >= max_items) {
|
||||||
@@ -142,18 +140,18 @@ export class MediaController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// collect items to delete
|
// collect items to delete
|
||||||
const deleteRes = await this.media.em.repo(mediaEntity).findMany({
|
const deleteRes = await this.media.em.repo(media_entity).findMany({
|
||||||
select: [id_field],
|
select: ["path"],
|
||||||
where: mediaRef,
|
where: mediaRef,
|
||||||
sort: {
|
sort: {
|
||||||
by: id_field,
|
by: "id",
|
||||||
dir: "asc"
|
dir: "asc"
|
||||||
},
|
},
|
||||||
limit: count - max_items + 1
|
limit: count - max_items + 1
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deleteRes.data && deleteRes.data.length > 0) {
|
if (deleteRes.data && deleteRes.data.length > 0) {
|
||||||
deleteRes.data.map((item) => ids_to_delete.push(item[id_field]));
|
deleteRes.data.map((item) => paths_to_delete.push(item.path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +169,7 @@ export class MediaController extends Controller {
|
|||||||
const file_name = getRandomizedFilename(file as File);
|
const file_name = getRandomizedFilename(file as File);
|
||||||
const info = await this.getStorage().uploadFile(file, file_name, true);
|
const info = await this.getStorage().uploadFile(file, file_name, true);
|
||||||
|
|
||||||
const mutator = this.media.em.mutator(mediaEntity);
|
const mutator = this.media.em.mutator(media_entity);
|
||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
const result = await mutator.insertOne({
|
const result = await mutator.insertOne({
|
||||||
...this.media.uploadedEventDataToMediaPayload(info),
|
...this.media.uploadedEventDataToMediaPayload(info),
|
||||||
@@ -180,10 +178,11 @@ export class MediaController extends Controller {
|
|||||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||||
|
|
||||||
// delete items if needed
|
// delete items if needed
|
||||||
if (ids_to_delete.length > 0) {
|
if (paths_to_delete.length > 0) {
|
||||||
await this.media.em
|
// delete files from db & adapter
|
||||||
.mutator(mediaEntity)
|
for (const path of paths_to_delete) {
|
||||||
.deleteWhere({ [id_field]: { $in: ids_to_delete } });
|
await this.getStorage().deleteFile(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ ok: true, result: result.data, ...info });
|
return c.json({ ok: true, result: result.data, ...info });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Const, Type, objectTransform } from "core/utils";
|
import { Const, type Static, Type, objectTransform } from "core/utils";
|
||||||
import { Adapters } from "media";
|
import { Adapters } from "media";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
|
|
||||||
@@ -47,3 +47,4 @@ export function buildMediaSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mediaConfigSchema = buildMediaSchema();
|
export const mediaConfigSchema = buildMediaSchema();
|
||||||
|
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export const useEntityQuery = <
|
|||||||
return {
|
return {
|
||||||
...swr,
|
...swr,
|
||||||
...mapped,
|
...mapped,
|
||||||
|
mutate: mutateAll,
|
||||||
|
mutateRaw: swr.mutate,
|
||||||
api,
|
api,
|
||||||
key
|
key
|
||||||
};
|
};
|
||||||
|
|||||||
2
app/src/ui/elements/index.ts
Normal file
2
app/src/ui/elements/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Auth } from "ui/modules/auth/index";
|
||||||
|
export * from "./media";
|
||||||
15
app/src/ui/elements/media.ts
Normal file
15
app/src/ui/elements/media.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
|
||||||
|
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||||
|
|
||||||
|
export const Media = {
|
||||||
|
Dropzone: DropzoneContainer,
|
||||||
|
Preview: PreviewWrapperMemoized
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PreviewComponentProps,
|
||||||
|
FileState,
|
||||||
|
DropzoneProps,
|
||||||
|
DropzoneRenderProps
|
||||||
|
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||||
|
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||||
@@ -144,7 +144,7 @@ export function Header({ hasSidebar = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu() {
|
function UserMenu() {
|
||||||
const { adminOverride } = useBknd();
|
const { adminOverride, config } = useBknd();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { logout_route } = useBkndWindowContext();
|
const { logout_route } = useBkndWindowContext();
|
||||||
@@ -163,10 +163,16 @@ function UserMenu() {
|
|||||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (config.auth.enabled) {
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||||
} else {
|
} else {
|
||||||
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff });
|
items.push({
|
||||||
|
label: `Logout ${auth.user.email}`,
|
||||||
|
onClick: handleLogout,
|
||||||
|
icon: IconKeyOff
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminOverride) {
|
if (!adminOverride) {
|
||||||
|
|||||||
128
app/src/ui/modules/auth/AuthForm.tsx
Normal file
128
app/src/ui/modules/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { ValueError } from "@sinclair/typebox/value";
|
||||||
|
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||||
|
import { type TSchema, Type, Value } from "core/utils";
|
||||||
|
import { Form, type Validator } from "json-schema-form-react";
|
||||||
|
import { transform } from "lodash-es";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import { Group, Input, Label } from "ui/components/form/Formy";
|
||||||
|
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||||
|
|
||||||
|
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||||
|
className?: string;
|
||||||
|
formData?: any;
|
||||||
|
action: "login" | "register";
|
||||||
|
method?: "POST" | "GET";
|
||||||
|
auth?: Partial<Pick<AppAuthSchema, "basepath" | "strategies">>;
|
||||||
|
buttonLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TypeboxValidator implements Validator<ValueError> {
|
||||||
|
async validate(schema: TSchema, data: any) {
|
||||||
|
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = new TypeboxValidator();
|
||||||
|
|
||||||
|
const schema = Type.Object({
|
||||||
|
email: Type.String({
|
||||||
|
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||||
|
}),
|
||||||
|
password: Type.String({
|
||||||
|
minLength: 8 // @todo: this should be configurable
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthForm({
|
||||||
|
formData,
|
||||||
|
className,
|
||||||
|
method = "POST",
|
||||||
|
action,
|
||||||
|
auth,
|
||||||
|
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
||||||
|
...props
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const basepath = auth?.basepath ?? "/api/auth";
|
||||||
|
const password = {
|
||||||
|
action: `${basepath}/password/${action}`,
|
||||||
|
strategy: auth?.strategies?.password ?? ({ type: "password" } as const)
|
||||||
|
};
|
||||||
|
|
||||||
|
const oauth = transform(
|
||||||
|
auth?.strategies ?? {},
|
||||||
|
(result, value, key) => {
|
||||||
|
if (value.type !== "password") {
|
||||||
|
result[key] = value.config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
) as Record<string, AppAuthOAuthStrategy>;
|
||||||
|
const has_oauth = Object.keys(oauth).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
{has_oauth && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
||||||
|
<SocialLink
|
||||||
|
provider={name}
|
||||||
|
method={method}
|
||||||
|
basepath={basepath}
|
||||||
|
key={key}
|
||||||
|
action={action}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Or />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
method={method}
|
||||||
|
action={password.action}
|
||||||
|
{...props}
|
||||||
|
schema={schema}
|
||||||
|
validator={validator}
|
||||||
|
validationMode="change"
|
||||||
|
className={twMerge("flex flex-col gap-3 w-full", className)}
|
||||||
|
>
|
||||||
|
{({ errors, submitting }) => (
|
||||||
|
<>
|
||||||
|
<Group>
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input type="email" name="email" />
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input type="password" name="password" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
className="w-full mt-2 justify-center"
|
||||||
|
disabled={errors.length > 0 || submitting}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Or = () => (
|
||||||
|
<div className="w-full flex flex-row items-center">
|
||||||
|
<div className="relative flex grow">
|
||||||
|
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||||
|
</div>
|
||||||
|
<div className="mx-5">or</div>
|
||||||
|
<div className="relative flex grow">
|
||||||
|
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
41
app/src/ui/modules/auth/AuthScreen.tsx
Normal file
41
app/src/ui/modules/auth/AuthScreen.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
||||||
|
import { Logo } from "ui/components/display/Logo";
|
||||||
|
import { Link } from "ui/components/wouter/Link";
|
||||||
|
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||||
|
|
||||||
|
export type AuthScreenProps = {
|
||||||
|
method?: "POST" | "GET";
|
||||||
|
action?: "login" | "register";
|
||||||
|
logo?: ReactNode;
|
||||||
|
intro?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
|
||||||
|
const { strategies, basepath, loading } = useAuthStrategies();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
|
||||||
|
{!loading && (
|
||||||
|
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
||||||
|
{typeof logo !== "undefined" ? (
|
||||||
|
logo
|
||||||
|
) : (
|
||||||
|
<Link href={"/"} className="link">
|
||||||
|
<Logo scale={0.25} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{typeof intro !== "undefined" ? (
|
||||||
|
intro
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
||||||
|
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
|
||||||
import { Type } from "core/utils";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import { Button } from "ui/components/buttons/Button";
|
|
||||||
import * as Formy from "ui/components/form/Formy";
|
|
||||||
|
|
||||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
|
|
||||||
className?: string;
|
|
||||||
formData?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const schema = Type.Object({
|
|
||||||
email: Type.String({
|
|
||||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
|
||||||
}),
|
|
||||||
password: Type.String({
|
|
||||||
minLength: 8 // @todo: this should be configurable
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { isValid, errors }
|
|
||||||
} = useForm({
|
|
||||||
mode: "onChange",
|
|
||||||
defaultValues: formData,
|
|
||||||
resolver: typeboxResolver(schema)
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form {...props} method={method} className={twMerge("flex flex-col gap-3 w-full", className)}>
|
|
||||||
<Formy.Group>
|
|
||||||
<Formy.Label htmlFor="email">Email address</Formy.Label>
|
|
||||||
<Formy.Input type="email" {...register("email")} />
|
|
||||||
</Formy.Group>
|
|
||||||
<Formy.Group>
|
|
||||||
<Formy.Label htmlFor="password">Password</Formy.Label>
|
|
||||||
<Formy.Input type="password" {...register("password")} />
|
|
||||||
</Formy.Group>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
size="large"
|
|
||||||
className="w-full mt-2 justify-center"
|
|
||||||
disabled={!isValid}
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
33
app/src/ui/modules/auth/SocialLink.tsx
Normal file
33
app/src/ui/modules/auth/SocialLink.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import type { IconType } from "ui/components/buttons/IconButton";
|
||||||
|
|
||||||
|
export type SocialLinkProps = {
|
||||||
|
label?: string;
|
||||||
|
provider: string;
|
||||||
|
icon?: IconType;
|
||||||
|
action: "login" | "register";
|
||||||
|
method?: "GET" | "POST";
|
||||||
|
basepath?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SocialLink({
|
||||||
|
label,
|
||||||
|
provider,
|
||||||
|
icon,
|
||||||
|
action,
|
||||||
|
method = "POST",
|
||||||
|
basepath = "/api/auth",
|
||||||
|
children
|
||||||
|
}: SocialLinkProps) {
|
||||||
|
return (
|
||||||
|
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
|
||||||
|
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
|
||||||
|
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
|
||||||
|
</Button>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/src/ui/modules/auth/index.ts
Normal file
9
app/src/ui/modules/auth/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||||
|
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||||
|
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||||
|
|
||||||
|
export const Auth = {
|
||||||
|
Screen: AuthScreen,
|
||||||
|
Form: AuthForm,
|
||||||
|
SocialLink: SocialLink
|
||||||
|
};
|
||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
} from "data";
|
} from "data";
|
||||||
import { MediaField } from "media/MediaField";
|
import { MediaField } from "media/MediaField";
|
||||||
import { type ComponentProps, Suspense } from "react";
|
import { type ComponentProps, Suspense } from "react";
|
||||||
import { useApi, useBaseUrl, useInvalidate } from "ui/client";
|
|
||||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||||
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 { Media } from "ui/elements";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
|
|
||||||
import { mediaItemsToFileStates } from "../../media/helper";
|
|
||||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||||
|
|
||||||
@@ -215,9 +213,6 @@ function EntityMediaFormField({
|
|||||||
}) {
|
}) {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const baseUrl = useBaseUrl();
|
|
||||||
const invalidate = useInvalidate();
|
|
||||||
const value = formApi.useStore((state) => {
|
const value = formApi.useStore((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 [];
|
||||||
@@ -225,37 +220,20 @@ function EntityMediaFormField({
|
|||||||
return [val];
|
return [val];
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialItems: FileState[] =
|
const key = JSON.stringify([entity, entityId, field.name, value.length]);
|
||||||
value.length === 0
|
|
||||||
? []
|
|
||||||
: mediaItemsToFileStates(value, {
|
|
||||||
baseUrl: api.baseUrl,
|
|
||||||
overrides: { state: "uploaded" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const getUploadInfo = useEvent(() => {
|
|
||||||
return {
|
|
||||||
url: api.media.getEntityUploadUrl(entity.name, entityId, field.name),
|
|
||||||
headers: api.media.getUploadHeaders(),
|
|
||||||
method: "POST"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = useEvent(async (file: FileState) => {
|
|
||||||
invalidate((api) => api.data.readOne(entity.name, entityId));
|
|
||||||
return api.media.deleteFile(file.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group>
|
<Formy.Group>
|
||||||
<FieldLabel field={field} />
|
<FieldLabel field={field} />
|
||||||
<Dropzone
|
<Media.Dropzone
|
||||||
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`}
|
key={key}
|
||||||
getUploadInfo={getUploadInfo}
|
|
||||||
handleDelete={handleDelete}
|
|
||||||
initialItems={initialItems}
|
|
||||||
maxItems={field.getMaxItems()}
|
maxItems={field.getMaxItems()}
|
||||||
autoUpload
|
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||||
|
entity={{
|
||||||
|
name: entity.name,
|
||||||
|
id: entityId,
|
||||||
|
field: field.name
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type ComponentPropsWithRef,
|
type ComponentPropsWithRef,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
memo,
|
memo,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -28,10 +29,11 @@ export type DropzoneRenderProps = {
|
|||||||
state: {
|
state: {
|
||||||
files: FileState[];
|
files: FileState[];
|
||||||
isOver: boolean;
|
isOver: boolean;
|
||||||
|
isOverAccepted: boolean;
|
||||||
showPlaceholder: boolean;
|
showPlaceholder: boolean;
|
||||||
};
|
};
|
||||||
actions: {
|
actions: {
|
||||||
uploadFileProgress: (file: FileState) => Promise<void>;
|
uploadFile: (file: FileState) => Promise<void>;
|
||||||
deleteFile: (file: FileState) => Promise<void>;
|
deleteFile: (file: FileState) => Promise<void>;
|
||||||
openFileInput: () => void;
|
openFileInput: () => void;
|
||||||
};
|
};
|
||||||
@@ -43,11 +45,16 @@ export type DropzoneProps = {
|
|||||||
handleDelete: (file: FileState) => Promise<boolean>;
|
handleDelete: (file: FileState) => Promise<boolean>;
|
||||||
initialItems?: FileState[];
|
initialItems?: FileState[];
|
||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
|
overwrite?: boolean;
|
||||||
autoUpload?: boolean;
|
autoUpload?: boolean;
|
||||||
|
onRejected?: (files: FileWithPath[]) => void;
|
||||||
|
onDeleted?: (file: FileState) => void;
|
||||||
|
onUploaded?: (file: FileState) => void;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Dropzone({
|
export function Dropzone({
|
||||||
@@ -55,23 +62,65 @@ export function Dropzone({
|
|||||||
handleDelete,
|
handleDelete,
|
||||||
initialItems = [],
|
initialItems = [],
|
||||||
maxItems,
|
maxItems,
|
||||||
|
overwrite,
|
||||||
autoUpload,
|
autoUpload,
|
||||||
placeholder
|
placeholder,
|
||||||
|
onRejected,
|
||||||
|
onDeleted,
|
||||||
|
onUploaded,
|
||||||
|
children
|
||||||
}: DropzoneProps) {
|
}: DropzoneProps) {
|
||||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
const [files, setFiles] = useState<FileState[]>(initialItems);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isOverAccepted, setIsOverAccepted] = useState(false);
|
||||||
|
|
||||||
|
function isMaxReached(added: number): boolean {
|
||||||
|
if (!maxItems) {
|
||||||
|
console.log("maxItems is undefined, never reached");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = files.length;
|
||||||
|
const remaining = maxItems - current;
|
||||||
|
console.log("isMaxReached", { added, current, remaining, maxItems, overwrite });
|
||||||
|
|
||||||
|
// if overwrite is set, but added is bigger than max items
|
||||||
|
if (overwrite) {
|
||||||
|
console.log("added > maxItems, stop?", added > maxItems);
|
||||||
|
return added > maxItems;
|
||||||
|
}
|
||||||
|
console.log("remaining > added, stop?", remaining > added);
|
||||||
|
// or remaining doesn't suffice, stop
|
||||||
|
return added > remaining;
|
||||||
|
}
|
||||||
|
|
||||||
const { isOver, handleFileInputChange, ref } = useDropzone({
|
const { isOver, handleFileInputChange, ref } = useDropzone({
|
||||||
onDropped: (newFiles: FileWithPath[]) => {
|
onDropped: (newFiles: FileWithPath[]) => {
|
||||||
if (maxItems && files.length + newFiles.length > maxItems) {
|
let to_drop = 0;
|
||||||
alert("Max items reached");
|
const added = newFiles.length;
|
||||||
|
|
||||||
|
if (maxItems) {
|
||||||
|
if (isMaxReached(added)) {
|
||||||
|
if (onRejected) {
|
||||||
|
onRejected(newFiles);
|
||||||
|
} else {
|
||||||
|
console.warn("maxItems reached");
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("files", newFiles);
|
to_drop = added;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("files", newFiles, { to_drop });
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
const currentPaths = prev.map((f) => f.path);
|
// drop amount calculated
|
||||||
|
const _prev = prev.slice(to_drop);
|
||||||
|
|
||||||
|
// prep new files
|
||||||
|
const currentPaths = _prev.map((f) => f.path);
|
||||||
const filteredFiles: FileState[] = newFiles
|
const filteredFiles: FileState[] = newFiles
|
||||||
.filter((f) => f.path && !currentPaths.includes(f.path))
|
.filter((f) => f.path && !currentPaths.includes(f.path))
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
@@ -84,7 +133,7 @@ export function Dropzone({
|
|||||||
progress: 0
|
progress: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...prev, ...filteredFiles];
|
return [..._prev, ...filteredFiles];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (autoUpload) {
|
if (autoUpload) {
|
||||||
@@ -92,17 +141,12 @@ export function Dropzone({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOver: (items) => {
|
onOver: (items) => {
|
||||||
if (maxItems && files.length + items.length >= maxItems) {
|
const max_reached = isMaxReached(items.length);
|
||||||
// indicate that the drop is not allowed
|
setIsOverAccepted(!max_reached);
|
||||||
return;
|
},
|
||||||
|
onLeave: () => {
|
||||||
|
setIsOverAccepted(false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
/*onOver: (items) =>
|
|
||||||
console.log(
|
|
||||||
"onOver",
|
|
||||||
items,
|
|
||||||
items.map((i) => [i.kind, i.type].join(":"))
|
|
||||||
)*/
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,7 +224,14 @@ export function Dropzone({
|
|||||||
formData.append("file", file.body);
|
formData.append("file", file.body);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open(method, url, true);
|
const urlWithParams = new URL(url);
|
||||||
|
if (overwrite) {
|
||||||
|
urlWithParams.searchParams.append("overwrite", "1");
|
||||||
|
}
|
||||||
|
console.log("url", urlWithParams.toString());
|
||||||
|
//return;
|
||||||
|
|
||||||
|
xhr.open(method, urlWithParams.toString(), true);
|
||||||
|
|
||||||
if (headers) {
|
if (headers) {
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
@@ -207,6 +258,8 @@ export function Dropzone({
|
|||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
//setFileState(file.path, "uploaded", 1);
|
//setFileState(file.path, "uploaded", 1);
|
||||||
console.log("Upload complete");
|
console.log("Upload complete");
|
||||||
|
onUploaded?.(file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(xhr.responseText);
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
|
||||||
@@ -252,6 +305,7 @@ export function Dropzone({
|
|||||||
setFileState(file.path, "deleting");
|
setFileState(file.path, "deleting");
|
||||||
await handleDelete(file);
|
await handleDelete(file);
|
||||||
removeFileFromState(file.path);
|
removeFileFromState(file.path);
|
||||||
|
onDeleted?.(file);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -262,54 +316,61 @@ export function Dropzone({
|
|||||||
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
|
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
|
||||||
);
|
);
|
||||||
|
|
||||||
const Component = DropzoneInner;
|
const renderProps: DropzoneRenderProps = {
|
||||||
|
wrapperRef: ref,
|
||||||
return (
|
inputProps: {
|
||||||
<Component
|
|
||||||
wrapperRef={ref}
|
|
||||||
inputProps={{
|
|
||||||
ref: inputRef,
|
ref: inputRef,
|
||||||
type: "file",
|
type: "file",
|
||||||
multiple: !maxItems || maxItems > 1,
|
multiple: !maxItems || maxItems > 1,
|
||||||
onChange: handleFileInputChange
|
onChange: handleFileInputChange
|
||||||
}}
|
},
|
||||||
state={{ files, isOver, showPlaceholder }}
|
state: {
|
||||||
actions={{ uploadFileProgress, deleteFile, openFileInput }}
|
files,
|
||||||
dropzoneProps={{ maxItems, placeholder, autoUpload }}
|
isOver,
|
||||||
/>
|
isOverAccepted,
|
||||||
);
|
showPlaceholder
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
uploadFile: uploadFileProgress,
|
||||||
|
deleteFile,
|
||||||
|
openFileInput
|
||||||
|
},
|
||||||
|
dropzoneProps: {
|
||||||
|
maxItems,
|
||||||
|
placeholder,
|
||||||
|
autoUpload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropzoneInner = ({
|
const DropzoneInner = ({
|
||||||
wrapperRef,
|
wrapperRef,
|
||||||
inputProps,
|
inputProps,
|
||||||
state: { files, isOver, showPlaceholder },
|
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||||
actions: { uploadFileProgress, deleteFile, openFileInput },
|
actions: { uploadFile, deleteFile, openFileInput },
|
||||||
dropzoneProps: { placeholder }
|
dropzoneProps: { placeholder }
|
||||||
}: DropzoneRenderProps) => {
|
}: DropzoneRenderProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
/*data-drag-over={"1"}*/
|
className={twMerge(
|
||||||
data-drag-over={isOver ? "1" : undefined}
|
"dropzone w-full h-full align-start flex flex-col select-none",
|
||||||
className="dropzone data-[drag-over]:bg-green-200/10 w-full h-full align-start flex flex-col select-none"
|
isOver && isOverAccepted && "bg-green-200/10",
|
||||||
|
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
<input
|
<input {...inputProps} />
|
||||||
{...inputProps}
|
|
||||||
/*ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
multiple={!maxItems || maxItems > 1}
|
|
||||||
onChange={handleFileInputChange}*/
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
||||||
{files.map((file, i) => (
|
{files.map((file) => (
|
||||||
<Preview
|
<Preview
|
||||||
key={file.path}
|
key={file.path}
|
||||||
file={file}
|
file={file}
|
||||||
handleUpload={uploadFileProgress}
|
handleUpload={uploadFile}
|
||||||
handleDelete={deleteFile}
|
handleDelete={deleteFile}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -333,18 +394,29 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wrapper = ({ file }: { file: FileState }) => {
|
export type PreviewComponentProps = {
|
||||||
|
file: FileState;
|
||||||
|
fallback?: (props: { file: FileState }) => JSX.Element;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
onTouchStart?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
|
||||||
if (file.type.startsWith("image/")) {
|
if (file.type.startsWith("image/")) {
|
||||||
return <ImagePreview file={file} />;
|
return <ImagePreview {...props} file={file} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type.startsWith("video/")) {
|
if (file.type.startsWith("video/")) {
|
||||||
return <VideoPreview file={file} />;
|
return <VideoPreview {...props} file={file} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <FallbackPreview file={file} />;
|
return fallback ? fallback({ file }) : null;
|
||||||
};
|
};
|
||||||
const WrapperMemoized = memo(Wrapper, (prev, next) => prev.file.path === next.file.path);
|
export const PreviewWrapperMemoized = memo(
|
||||||
|
Wrapper,
|
||||||
|
(prev, next) => prev.file.path === next.file.path
|
||||||
|
);
|
||||||
|
|
||||||
type PreviewProps = {
|
type PreviewProps = {
|
||||||
file: FileState;
|
file: FileState;
|
||||||
@@ -370,7 +442,6 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
|||||||
file.state === "deleting" && "opacity-70"
|
file.state === "deleting" && "opacity-70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/*{file.state}*/}
|
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<Dropdown items={dropdownItems} position="bottom-end">
|
<Dropdown items={dropdownItems} position="bottom-end">
|
||||||
<IconButton Icon={TbDots} />
|
<IconButton Icon={TbDots} />
|
||||||
@@ -385,7 +456,11 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
|
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
|
||||||
<WrapperMemoized file={file} />
|
<PreviewWrapperMemoized
|
||||||
|
file={file}
|
||||||
|
fallback={FallbackPreview}
|
||||||
|
className="max-w-full max-h-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-1.5 py-1">
|
<div className="flex flex-col px-1.5 py-1">
|
||||||
<p className="truncate">{file.name}</p>
|
<p className="truncate">{file.name}</p>
|
||||||
@@ -398,14 +473,20 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImagePreview = ({ file }: { file: FileState }) => {
|
const ImagePreview = ({
|
||||||
|
file,
|
||||||
|
...props
|
||||||
|
}: { file: FileState } & ComponentPropsWithoutRef<"img">) => {
|
||||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||||
return <img className="max-w-full max-h-full" src={objectUrl} />;
|
return <img {...props} src={objectUrl} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VideoPreview = ({ file }: { file: FileState }) => {
|
const VideoPreview = ({
|
||||||
|
file,
|
||||||
|
...props
|
||||||
|
}: { file: FileState } & ComponentPropsWithoutRef<"video">) => {
|
||||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||||
return <video src={objectUrl} />;
|
return <video {...props} src={objectUrl} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FallbackPreview = ({ file }: { file: FileState }) => {
|
const FallbackPreview = ({ file }: { file: FileState }) => {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { RepoQuery } 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 { useEvent } from "ui/hooks/use-event";
|
||||||
|
import {
|
||||||
|
Dropzone,
|
||||||
|
type DropzoneProps,
|
||||||
|
type DropzoneRenderProps,
|
||||||
|
type FileState
|
||||||
|
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||||
|
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
||||||
|
|
||||||
|
export type DropzoneContainerProps = {
|
||||||
|
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||||
|
initialItems?: MediaFieldSchema[];
|
||||||
|
entity?: {
|
||||||
|
name: string;
|
||||||
|
id: number;
|
||||||
|
field: string;
|
||||||
|
};
|
||||||
|
query?: Partial<RepoQuery>;
|
||||||
|
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
|
||||||
|
Partial<DropzoneProps>;
|
||||||
|
|
||||||
|
export function DropzoneContainer({
|
||||||
|
initialItems,
|
||||||
|
basepath = "/api/media",
|
||||||
|
storage = {},
|
||||||
|
entity_name = "media",
|
||||||
|
entity,
|
||||||
|
query,
|
||||||
|
...props
|
||||||
|
}: DropzoneContainerProps) {
|
||||||
|
const id = useId();
|
||||||
|
const baseUrl = useBaseUrl();
|
||||||
|
const api = useApi();
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
|
||||||
|
|
||||||
|
const $q = useEntityQuery(
|
||||||
|
entity_name as "media",
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
...query,
|
||||||
|
limit,
|
||||||
|
where: entity
|
||||||
|
? {
|
||||||
|
reference: `${entity.name}.${entity.field}`,
|
||||||
|
entity_id: entity.id,
|
||||||
|
...query?.where
|
||||||
|
}
|
||||||
|
: query?.where
|
||||||
|
},
|
||||||
|
{ enabled: !initialItems }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getUploadInfo = useEvent((file) => {
|
||||||
|
const url = entity
|
||||||
|
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
|
||||||
|
: api.media.getFileUploadUrl(file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
headers: api.media.getUploadHeaders(),
|
||||||
|
method: "POST"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = useEvent(async () => {
|
||||||
|
if (entity) {
|
||||||
|
invalidate((api) => api.data.readOne(entity.name, entity.id));
|
||||||
|
}
|
||||||
|
await $q.mutate();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = useEvent(async (file: FileState) => {
|
||||||
|
return api.media.deleteFile(file.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualItems = initialItems ?? (($q.data || []) as MediaFieldSchema[]);
|
||||||
|
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
|
||||||
|
|
||||||
|
const key = id + JSON.stringify(_initialItems);
|
||||||
|
return (
|
||||||
|
<Dropzone
|
||||||
|
key={id + key}
|
||||||
|
getUploadInfo={getUploadInfo}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
onUploaded={refresh}
|
||||||
|
onDeleted={refresh}
|
||||||
|
autoUpload
|
||||||
|
initialItems={_initialItems}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ import { type FileWithPath, fromEvent } from "./file-selector";
|
|||||||
type DropzoneProps = {
|
type DropzoneProps = {
|
||||||
onDropped: (files: FileWithPath[]) => void;
|
onDropped: (files: FileWithPath[]) => void;
|
||||||
onOver?: (items: DataTransferItem[]) => void;
|
onOver?: (items: DataTransferItem[]) => void;
|
||||||
|
onLeave?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const events = {
|
const events = {
|
||||||
enter: ["dragenter", "dragover", "dragstart"],
|
enter: ["dragenter", "dragover", "dragstart"],
|
||||||
leave: ["dragleave", "drop"],
|
leave: ["dragleave", "drop"]
|
||||||
};
|
};
|
||||||
const allEvents = [...events.enter, ...events.leave];
|
const allEvents = [...events.enter, ...events.leave];
|
||||||
|
|
||||||
export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) {
|
||||||
const [isOver, setIsOver] = useState(false);
|
const [isOver, setIsOver] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const onOverCalled = useRef(false);
|
const onOverCalled = useRef(false);
|
||||||
@@ -31,8 +32,10 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsOver(_isOver);
|
setIsOver(_isOver);
|
||||||
|
|
||||||
if (_isOver === false && onOverCalled.current) {
|
if (_isOver === false && onOverCalled.current) {
|
||||||
onOverCalled.current = false;
|
onOverCalled.current = false;
|
||||||
|
onLeave?.();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
|||||||
onDropped?.(files as any);
|
onDropped?.(files as any);
|
||||||
onOverCalled.current = false;
|
onOverCalled.current = false;
|
||||||
},
|
},
|
||||||
[onDropped],
|
[onDropped]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileInputChange = useCallback(
|
const handleFileInputChange = useCallback(
|
||||||
@@ -50,7 +53,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
|||||||
const files = await fromEvent(e);
|
const files = await fromEvent(e);
|
||||||
onDropped?.(files as any);
|
onDropped?.(files as any);
|
||||||
},
|
},
|
||||||
[onDropped],
|
[onDropped]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,81 +1,7 @@
|
|||||||
import type { AppAuthOAuthStrategy } from "auth/auth-schema";
|
|
||||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
|
||||||
import { transform } from "lodash-es";
|
|
||||||
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
|
||||||
import { Button } from "ui/components/buttons/Button";
|
|
||||||
import { Logo } from "ui/components/display/Logo";
|
|
||||||
import { Link } from "ui/components/wouter/Link";
|
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import { LoginForm } from "ui/modules/auth/LoginForm";
|
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
|
||||||
|
|
||||||
export function AuthLogin() {
|
export function AuthLogin() {
|
||||||
useBrowserTitle(["Login"]);
|
useBrowserTitle(["Login"]);
|
||||||
const { strategies, basepath, loading } = useAuthStrategies();
|
return <AuthScreen action="login" />;
|
||||||
|
|
||||||
const oauth = transform(
|
|
||||||
strategies ?? {},
|
|
||||||
(result, value, key) => {
|
|
||||||
if (value.type !== "password") {
|
|
||||||
result[key] = value.config;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
) as Record<string, AppAuthOAuthStrategy>;
|
|
||||||
//console.log("oauth", oauth, strategies);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell.Root>
|
|
||||||
<AppShell.Content center>
|
|
||||||
{!loading && (
|
|
||||||
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
|
||||||
<Link href={"/"} className="link">
|
|
||||||
<Logo scale={0.25} />
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
|
||||||
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
|
||||||
{Object.keys(oauth).length > 0 && (
|
|
||||||
<>
|
|
||||||
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action={`${basepath}/${name}/login`}
|
|
||||||
key={key}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
type="submit"
|
|
||||||
size="large"
|
|
||||||
variant="outline"
|
|
||||||
className="justify-center w-full"
|
|
||||||
>
|
|
||||||
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row items-center">
|
|
||||||
<div className="relative flex grow">
|
|
||||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
|
||||||
</div>
|
|
||||||
<div className="mx-5">or</div>
|
|
||||||
<div className="relative flex grow">
|
|
||||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LoginForm action="/api/auth/password/login" />
|
|
||||||
{/*<a href="/auth/logout">Logout</a>*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AppShell.Content>
|
|
||||||
</AppShell.Root>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { IconPhoto } from "@tabler/icons-react";
|
import { IconPhoto } from "@tabler/icons-react";
|
||||||
import type { MediaFieldSchema } from "modules";
|
|
||||||
import { TbSettings } from "react-icons/tb";
|
import { TbSettings } from "react-icons/tb";
|
||||||
import { useApi, useBaseUrl, useEntityQuery } from "ui/client";
|
|
||||||
import { useBknd } from "ui/client/BkndProvider";
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Empty } from "ui/components/display/Empty";
|
import { Empty } from "ui/components/display/Empty";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
|
import { Media } from "ui/elements";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { Dropzone, type FileState } from "ui/modules/media/components/dropzone/Dropzone";
|
|
||||||
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
export function MediaRoot({ children }) {
|
export function MediaRoot({ children }) {
|
||||||
@@ -63,35 +59,11 @@ export function MediaRoot({ children }) {
|
|||||||
// @todo: add infinite load
|
// @todo: add infinite load
|
||||||
export function MediaEmpty() {
|
export function MediaEmpty() {
|
||||||
useBrowserTitle(["Media"]);
|
useBrowserTitle(["Media"]);
|
||||||
const baseUrl = useBaseUrl();
|
|
||||||
const api = useApi();
|
|
||||||
const $q = useEntityQuery("media", undefined, { limit: 50 });
|
|
||||||
|
|
||||||
const getUploadInfo = useEvent((file) => {
|
|
||||||
return {
|
|
||||||
url: api.media.getFileUploadUrl(file),
|
|
||||||
headers: api.media.getUploadHeaders(),
|
|
||||||
method: "POST"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = useEvent(async (file: FileState) => {
|
|
||||||
return api.media.deleteFile(file.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
const media = ($q.data || []) as MediaFieldSchema[];
|
|
||||||
const initialItems = mediaItemsToFileStates(media, { baseUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div className="flex flex-1 p-3">
|
<div className="flex flex-1 p-3">
|
||||||
<Dropzone
|
<Media.Dropzone />
|
||||||
key={$q.isLoading ? "loaded" : "initial"}
|
|
||||||
getUploadInfo={getUploadInfo}
|
|
||||||
handleDelete={handleDelete}
|
|
||||||
autoUpload
|
|
||||||
initialItems={initialItems}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import FlowFormTest from "../../routes/test/tests/flow-form-test";
|
|||||||
import ModalTest from "../../routes/test/tests/modal-test";
|
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 EntityFieldsForm from "./tests/entity-fields-form";
|
import EntityFieldsForm from "./tests/entity-fields-form";
|
||||||
import FlowsTest from "./tests/flows-test";
|
import FlowsTest from "./tests/flows-test";
|
||||||
import JsonFormTest from "./tests/jsonform-test";
|
import JsonFormTest from "./tests/jsonform-test";
|
||||||
@@ -41,7 +42,8 @@ const tests = {
|
|||||||
AppShellAccordionsTest,
|
AppShellAccordionsTest,
|
||||||
SwaggerTest,
|
SwaggerTest,
|
||||||
SWRAndAPI,
|
SWRAndAPI,
|
||||||
SwrAndDataApi
|
SwrAndDataApi,
|
||||||
|
DropzoneElementTest
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function TestRoutes() {
|
export default function TestRoutes() {
|
||||||
|
|||||||
78
app/src/ui/routes/test/tests/dropzone-element-test.tsx
Normal file
78
app/src/ui/routes/test/tests/dropzone-element-test.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { type DropzoneRenderProps, Media } from "ui/elements";
|
||||||
|
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||||
|
|
||||||
|
export default function DropzoneElementTest() {
|
||||||
|
return (
|
||||||
|
<Scrollable>
|
||||||
|
<div className="flex flex-col w-full h-full p-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<b>Dropzone User Avatar 1 (fully customized)</b>
|
||||||
|
<Media.Dropzone
|
||||||
|
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||||
|
maxItems={1}
|
||||||
|
overwrite
|
||||||
|
>
|
||||||
|
{(props) => <CustomUserAvatarDropzone {...props} />}
|
||||||
|
</Media.Dropzone>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone User Avatar 1 (overwrite)</b>
|
||||||
|
<Media.Dropzone
|
||||||
|
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||||
|
maxItems={1}
|
||||||
|
overwrite
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone User Avatar 1</b>
|
||||||
|
<Media.Dropzone entity={{ name: "users", id: 1, field: "avatar" }} maxItems={1} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone Container blank w/ query</b>
|
||||||
|
<Media.Dropzone query={{ limit: 2 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone Container blank</b>
|
||||||
|
<Media.Dropzone />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone Post 12</b>
|
||||||
|
<Media.Dropzone entity={{ name: "posts", id: 12, field: "images" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Scrollable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomUserAvatarDropzone({
|
||||||
|
wrapperRef,
|
||||||
|
inputProps,
|
||||||
|
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||||
|
actions: { openFileInput }
|
||||||
|
}: DropzoneRenderProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user