diff --git a/app/build.ts b/app/build.ts index e982777..7c54b54 100644 --- a/app/build.ts +++ b/app/build.ts @@ -138,7 +138,12 @@ await tsup.build({ minify, sourcemap, 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", external: ["bun:test", "react", "react-dom", "use-sync-external-store"], metafile: true, diff --git a/app/package.json b/app/package.json index 8efb6c9..d2c52b3 100644 --- a/app/package.json +++ b/app/package.json @@ -34,7 +34,8 @@ "liquidjs": "^10.15.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", - "swr": "^2.2.5" + "swr": "^2.2.5", + "json-schema-form-react": "link:json-schema-form-react" }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", @@ -103,6 +104,11 @@ "import": "./dist/ui/index.js", "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": { "types": "./dist/types/ui/client/index.d.ts", "import": "./dist/ui/client/index.js", diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 202e0b4..84882b5 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => { const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject)); export type AppAuthStrategies = Static; export type AppAuthOAuthStrategy = Static; +export type AppAuthCustomOAuthStrategy = Static; const guardConfigSchema = Type.Object({ enabled: Type.Optional(Type.Boolean({ default: false })) diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index fcbbddc..8f21483 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -2,8 +2,7 @@ import { tbValidator as tb } from "core"; import { Type } from "core/utils"; import { bodyLimit } from "hono/body-limit"; import type { StorageAdapter } from "media"; -import { StorageEvents } from "media"; -import { getRandomizedFilename } from "media"; +import { StorageEvents, getRandomizedFilename } from "media"; import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; @@ -109,7 +108,7 @@ export class MediaController extends Controller { 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 mediaRef = { scope: field_name, @@ -119,11 +118,10 @@ export class MediaController extends Controller { // check max items const max_items = field.getMaxItems(); - const ids_to_delete: number[] = []; - const id_field = mediaEntity.getPrimaryField().name; + const paths_to_delete: string[] = []; if (max_items) { 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 (count >= max_items) { @@ -142,18 +140,18 @@ export class MediaController extends Controller { } // collect items to delete - const deleteRes = await this.media.em.repo(mediaEntity).findMany({ - select: [id_field], + const deleteRes = await this.media.em.repo(media_entity).findMany({ + select: ["path"], where: mediaRef, sort: { - by: id_field, + by: "id", dir: "asc" }, limit: count - max_items + 1 }); 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 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); const result = await mutator.insertOne({ ...this.media.uploadedEventDataToMediaPayload(info), @@ -180,10 +178,11 @@ export class MediaController extends Controller { mutator.__unstable_toggleSystemEntityCreation(true); // delete items if needed - if (ids_to_delete.length > 0) { - await this.media.em - .mutator(mediaEntity) - .deleteWhere({ [id_field]: { $in: ids_to_delete } }); + if (paths_to_delete.length > 0) { + // delete files from db & adapter + for (const path of paths_to_delete) { + await this.getStorage().deleteFile(path); + } } return c.json({ ok: true, result: result.data, ...info }); @@ -192,4 +191,4 @@ export class MediaController extends Controller { return hono; } -} \ No newline at end of file +} diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 045a0ca..64a52ba 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -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 { registries } from "modules/registries"; @@ -47,3 +47,4 @@ export function buildMediaSchema() { } export const mediaConfigSchema = buildMediaSchema(); +export type TAppMediaConfig = Static; diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 4900972..5f4da99 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -143,6 +143,8 @@ export const useEntityQuery = < return { ...swr, ...mapped, + mutate: mutateAll, + mutateRaw: swr.mutate, api, key }; diff --git a/app/src/ui/elements/index.ts b/app/src/ui/elements/index.ts new file mode 100644 index 0000000..83a292b --- /dev/null +++ b/app/src/ui/elements/index.ts @@ -0,0 +1,2 @@ +export { Auth } from "ui/modules/auth/index"; +export * from "./media"; diff --git a/app/src/ui/elements/media.ts b/app/src/ui/elements/media.ts new file mode 100644 index 0000000..5ed6e11 --- /dev/null +++ b/app/src/ui/elements/media.ts @@ -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"; diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 0d1c701..6343772 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -144,7 +144,7 @@ export function Header({ hasSidebar = true }) { } function UserMenu() { - const { adminOverride } = useBknd(); + const { adminOverride, config } = useBknd(); const auth = useAuth(); const [navigate] = useNavigate(); const { logout_route } = useBkndWindowContext(); @@ -163,10 +163,16 @@ function UserMenu() { { label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings } ]; - if (!auth.user) { - items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); - } else { - items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff }); + if (config.auth.enabled) { + if (!auth.user) { + items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); + } else { + items.push({ + label: `Logout ${auth.user.email}`, + onClick: handleLogout, + icon: IconKeyOff + }); + } } if (!adminOverride) { diff --git a/app/src/ui/modules/auth/AuthForm.tsx b/app/src/ui/modules/auth/AuthForm.tsx new file mode 100644 index 0000000..fa864ef --- /dev/null +++ b/app/src/ui/modules/auth/AuthForm.tsx @@ -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, "onSubmit" | "action"> & { + className?: string; + formData?: any; + action: "login" | "register"; + method?: "POST" | "GET"; + auth?: Partial>; + buttonLabel?: string; +}; + +class TypeboxValidator implements Validator { + 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; + const has_oauth = Object.keys(oauth).length > 0; + + return ( +
+ {has_oauth && ( + <> +
+ {Object.entries(oauth)?.map(([name, oauth], key) => ( + + ))} +
+ + + )} +
+ {({ errors, submitting }) => ( + <> + + + + + + + + + + + + )} +
+
+ ); +} + +const Or = () => ( +
+
+
+
+
or
+
+
+
+
+); diff --git a/app/src/ui/modules/auth/AuthScreen.tsx b/app/src/ui/modules/auth/AuthScreen.tsx new file mode 100644 index 0000000..3ac60e1 --- /dev/null +++ b/app/src/ui/modules/auth/AuthScreen.tsx @@ -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 ( +
+ {!loading && ( +
+ {typeof logo !== "undefined" ? ( + logo + ) : ( + + + + )} + {typeof intro !== "undefined" ? ( + intro + ) : ( +
+

Sign in to your admin panel

+

Enter your credentials below to get access.

+
+ )} + +
+ )} +
+ ); +} diff --git a/app/src/ui/modules/auth/LoginForm.tsx b/app/src/ui/modules/auth/LoginForm.tsx deleted file mode 100644 index 4f1887d..0000000 --- a/app/src/ui/modules/auth/LoginForm.tsx +++ /dev/null @@ -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, "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 ( -
- - Email address - - - - Password - - - - -
- ); -} diff --git a/app/src/ui/modules/auth/SocialLink.tsx b/app/src/ui/modules/auth/SocialLink.tsx new file mode 100644 index 0000000..e116bb4 --- /dev/null +++ b/app/src/ui/modules/auth/SocialLink.tsx @@ -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 ( +
+ + {children} +
+ ); +} diff --git a/app/src/ui/modules/auth/index.ts b/app/src/ui/modules/auth/index.ts new file mode 100644 index 0000000..f3940d7 --- /dev/null +++ b/app/src/ui/modules/auth/index.ts @@ -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 +}; diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 131bd61..fb5fc5f 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -10,13 +10,11 @@ import { } from "data"; import { MediaField } from "media/MediaField"; import { type ComponentProps, Suspense } from "react"; -import { useApi, useBaseUrl, useInvalidate } from "ui/client"; import { JsonEditor } from "ui/components/code/JsonEditor"; import * as Formy 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 { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone"; -import { mediaItemsToFileStates } from "../../media/helper"; import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField"; import { EntityRelationalFormField } from "./fields/EntityRelationalFormField"; @@ -215,9 +213,6 @@ function EntityMediaFormField({ }) { if (!entityId) return; - const api = useApi(); - const baseUrl = useBaseUrl(); - const invalidate = useInvalidate(); const value = formApi.useStore((state) => { const val = state.values[field.name]; if (!val || typeof val === "undefined") return []; @@ -225,37 +220,20 @@ function EntityMediaFormField({ return [val]; }); - const initialItems: FileState[] = - 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); - }); + const key = JSON.stringify([entity, entityId, field.name, value.length]); return ( - ); diff --git a/app/src/ui/modules/media/components/dropzone/Dropzone.tsx b/app/src/ui/modules/media/components/dropzone/Dropzone.tsx index 19d2690..4f6fe20 100644 --- a/app/src/ui/modules/media/components/dropzone/Dropzone.tsx +++ b/app/src/ui/modules/media/components/dropzone/Dropzone.tsx @@ -1,5 +1,6 @@ import { type ComponentPropsWithRef, + type ComponentPropsWithoutRef, type RefObject, memo, useEffect, @@ -28,10 +29,11 @@ export type DropzoneRenderProps = { state: { files: FileState[]; isOver: boolean; + isOverAccepted: boolean; showPlaceholder: boolean; }; actions: { - uploadFileProgress: (file: FileState) => Promise; + uploadFile: (file: FileState) => Promise; deleteFile: (file: FileState) => Promise; openFileInput: () => void; }; @@ -43,11 +45,16 @@ export type DropzoneProps = { handleDelete: (file: FileState) => Promise; initialItems?: FileState[]; maxItems?: number; + overwrite?: boolean; autoUpload?: boolean; + onRejected?: (files: FileWithPath[]) => void; + onDeleted?: (file: FileState) => void; + onUploaded?: (file: FileState) => void; placeholder?: { show?: boolean; text?: string; }; + children?: (props: DropzoneRenderProps) => JSX.Element; }; export function Dropzone({ @@ -55,23 +62,65 @@ export function Dropzone({ handleDelete, initialItems = [], maxItems, + overwrite, autoUpload, - placeholder + placeholder, + onRejected, + onDeleted, + onUploaded, + children }: DropzoneProps) { const [files, setFiles] = useState(initialItems); const [uploading, setUploading] = useState(false); const inputRef = useRef(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({ onDropped: (newFiles: FileWithPath[]) => { - if (maxItems && files.length + newFiles.length > maxItems) { - alert("Max items reached"); - return; + let to_drop = 0; + const added = newFiles.length; + + if (maxItems) { + if (isMaxReached(added)) { + if (onRejected) { + onRejected(newFiles); + } else { + console.warn("maxItems reached"); + } + + return; + } + + to_drop = added; } - console.log("files", newFiles); + console.log("files", newFiles, { to_drop }); 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 .filter((f) => f.path && !currentPaths.includes(f.path)) .map((f) => ({ @@ -84,7 +133,7 @@ export function Dropzone({ progress: 0 })); - return [...prev, ...filteredFiles]; + return [..._prev, ...filteredFiles]; }); if (autoUpload) { @@ -92,17 +141,12 @@ export function Dropzone({ } }, onOver: (items) => { - if (maxItems && files.length + items.length >= maxItems) { - // indicate that the drop is not allowed - return; - } + const max_reached = isMaxReached(items.length); + setIsOverAccepted(!max_reached); + }, + onLeave: () => { + setIsOverAccepted(false); } - /*onOver: (items) => - console.log( - "onOver", - items, - items.map((i) => [i.kind, i.type].join(":")) - )*/ }); useEffect(() => { @@ -180,7 +224,14 @@ export function Dropzone({ formData.append("file", file.body); 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) { headers.forEach((value, key) => { @@ -207,6 +258,8 @@ export function Dropzone({ if (xhr.status === 200) { //setFileState(file.path, "uploaded", 1); console.log("Upload complete"); + onUploaded?.(file); + try { const response = JSON.parse(xhr.responseText); @@ -252,6 +305,7 @@ export function Dropzone({ setFileState(file.path, "deleting"); await handleDelete(file); removeFileFromState(file.path); + onDeleted?.(file); } break; } @@ -262,54 +316,61 @@ export function Dropzone({ placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems) ); - const Component = DropzoneInner; + const renderProps: DropzoneRenderProps = { + wrapperRef: ref, + inputProps: { + ref: inputRef, + type: "file", + multiple: !maxItems || maxItems > 1, + onChange: handleFileInputChange + }, + state: { + files, + isOver, + isOverAccepted, + showPlaceholder + }, + actions: { + uploadFile: uploadFileProgress, + deleteFile, + openFileInput + }, + dropzoneProps: { + maxItems, + placeholder, + autoUpload + } + }; - return ( - 1, - onChange: handleFileInputChange - }} - state={{ files, isOver, showPlaceholder }} - actions={{ uploadFileProgress, deleteFile, openFileInput }} - dropzoneProps={{ maxItems, placeholder, autoUpload }} - /> - ); + return children ? children(renderProps) : ; } const DropzoneInner = ({ wrapperRef, inputProps, - state: { files, isOver, showPlaceholder }, - actions: { uploadFileProgress, deleteFile, openFileInput }, + state: { files, isOver, isOverAccepted, showPlaceholder }, + actions: { uploadFile, deleteFile, openFileInput }, dropzoneProps: { placeholder } }: DropzoneRenderProps) => { return (
- 1} - onChange={handleFileInputChange}*/ - /> +
- {files.map((file, i) => ( + {files.map((file) => ( ))} @@ -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/")) { - return ; + return ; } if (file.type.startsWith("video/")) { - return ; + return ; } - return ; + 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 = { file: FileState; @@ -370,7 +442,6 @@ const Preview: React.FC = ({ file, handleUpload, handleDelete }) = file.state === "deleting" && "opacity-70" )} > - {/*{file.state}*/}
@@ -385,7 +456,11 @@ const Preview: React.FC = ({ file, handleUpload, handleDelete }) =
)}
- +

{file.name}

@@ -398,14 +473,20 @@ const Preview: React.FC = ({ 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); - return ; + return ; }; -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); - return