mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
add media detail dialog and infinite loading
This commit is contained in:
@@ -2,6 +2,7 @@ export * from "./browser";
|
||||
export * from "./objects";
|
||||
export * from "./strings";
|
||||
export * from "./perf";
|
||||
export * from "./file";
|
||||
export * from "./reqres";
|
||||
export * from "./xml";
|
||||
export type { Prettify, PrettifyRec } from "./types";
|
||||
|
||||
@@ -11,3 +11,14 @@ export function ensureInt(value?: string | number | null | undefined): number {
|
||||
|
||||
return typeof value === "number" ? value : Number.parseInt(value, 10);
|
||||
}
|
||||
|
||||
export const formatNumber = {
|
||||
fileSize: (bytes: number, decimals = 2): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -331,4 +331,15 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
|
||||
Boolean,
|
||||
);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.key({ search: true });
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
url: this.request.url,
|
||||
method: this.request.method,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Api } from "Api";
|
||||
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useApi } from "ui/client";
|
||||
import { useState } from "react";
|
||||
|
||||
export const useApiQuery = <
|
||||
Data,
|
||||
@@ -27,6 +29,50 @@ export const useApiQuery = <
|
||||
};
|
||||
};
|
||||
|
||||
/** @attention: highly experimental, use with caution! */
|
||||
export const useApiInfiniteQuery = <
|
||||
Data,
|
||||
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data,
|
||||
>(
|
||||
fn: (api: Api, page: number) => FetchPromise<Data>,
|
||||
options?: SWRConfiguration & { refine?: RefineFn },
|
||||
) => {
|
||||
const [endReached, setEndReached] = useState(false);
|
||||
const api = useApi();
|
||||
const promise = (page: number) => fn(api, page);
|
||||
const refine = options?.refine ?? ((data: any) => data);
|
||||
|
||||
type RefinedData = RefineFn extends (data: ResponseObject<Data>) => infer R ? R : Data;
|
||||
|
||||
// @ts-ignore
|
||||
const swr = useSWRInfinite<RefinedData>(
|
||||
(index, previousPageData: any) => {
|
||||
if (previousPageData && !previousPageData.length) {
|
||||
setEndReached(true);
|
||||
return null; // reached the end
|
||||
}
|
||||
return promise(index).request.url;
|
||||
},
|
||||
(url: string) => {
|
||||
return new FetchPromise(new Request(url), { fetcher: api.fetcher }, refine).execute();
|
||||
},
|
||||
{
|
||||
revalidateFirstPage: false,
|
||||
},
|
||||
);
|
||||
// @ts-ignore
|
||||
const data = swr.data ? [].concat(...swr.data) : [];
|
||||
return {
|
||||
...swr,
|
||||
_data: swr.data,
|
||||
data,
|
||||
endReached,
|
||||
promise: promise(swr.size),
|
||||
key: promise(swr.size).key(),
|
||||
api,
|
||||
};
|
||||
};
|
||||
|
||||
export const useInvalidate = (options?: { exact?: boolean }) => {
|
||||
const mutate = useSWRConfig().mutate;
|
||||
const api = useApi();
|
||||
|
||||
@@ -9,6 +9,7 @@ export const JsonViewer = ({
|
||||
expand = 0,
|
||||
showSize = false,
|
||||
showCopy = false,
|
||||
copyIconProps = {},
|
||||
className,
|
||||
}: {
|
||||
json: object;
|
||||
@@ -16,6 +17,7 @@ export const JsonViewer = ({
|
||||
expand?: number;
|
||||
showSize?: boolean;
|
||||
showCopy?: boolean;
|
||||
copyIconProps?: any;
|
||||
className?: string;
|
||||
}) => {
|
||||
const size = showSize ? JSON.stringify(json).length : undefined;
|
||||
@@ -28,7 +30,7 @@ export const JsonViewer = ({
|
||||
return (
|
||||
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
||||
{showContext && (
|
||||
<div className="absolute right-4 top-4 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
||||
<div className="absolute right-4 top-3 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
||||
{(title || size) && (
|
||||
<div className="flex flex-row">
|
||||
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
|
||||
@@ -36,7 +38,7 @@ export const JsonViewer = ({
|
||||
)}
|
||||
{showCopy && (
|
||||
<div>
|
||||
<IconButton Icon={TbCopy} onClick={onCopy} />
|
||||
<IconButton Icon={TbCopy} onClick={onCopy} {...copyIconProps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export type DropdownProps = {
|
||||
onClickItem?: (item: DropdownItem) => void;
|
||||
renderItem?: (
|
||||
item: DropdownItem,
|
||||
props: { key: number; onClick: () => void },
|
||||
props: { key: number; onClick: (e: any) => void },
|
||||
) => DropdownClickableChild;
|
||||
};
|
||||
|
||||
@@ -65,7 +65,13 @@ export function Dropdown({
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0),
|
||||
);
|
||||
|
||||
const onClickHandler = openEvent === "onClick" ? toggle : undefined;
|
||||
const onClickHandler =
|
||||
openEvent === "onClick"
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
}
|
||||
: undefined;
|
||||
const onContextMenuHandler = useEvent((e) => {
|
||||
if (openEvent !== "onContextMenu") return;
|
||||
e.preventDefault();
|
||||
@@ -165,10 +171,18 @@ export function Dropdown({
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50">{title}</div>
|
||||
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50 truncate">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{menuItems.map((item, i) =>
|
||||
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }),
|
||||
itemRenderer(item, {
|
||||
key: i,
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
internalOnClickItem(item);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -88,6 +88,7 @@ export function Link({
|
||||
}
|
||||
|
||||
const wouterOnClick = (e: any) => {
|
||||
onClick?.(e);
|
||||
// prepared for view transition
|
||||
/*if (props.transition !== false) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
import { formatNumber } from "core/utils";
|
||||
|
||||
export type FileState = {
|
||||
body: FileWithPath | string;
|
||||
@@ -41,6 +42,8 @@ export type DropzoneRenderProps = {
|
||||
deleteFile: (file: FileState) => Promise<void>;
|
||||
openFileInput: () => void;
|
||||
};
|
||||
onClick?: (file: FileState) => void;
|
||||
footer?: ReactNode;
|
||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||
};
|
||||
|
||||
@@ -56,10 +59,12 @@ export type DropzoneProps = {
|
||||
onRejected?: (files: FileWithPath[]) => void;
|
||||
onDeleted?: (file: FileState) => void;
|
||||
onUploaded?: (files: FileStateWithData[]) => void;
|
||||
onClick?: (file: FileState) => void;
|
||||
placeholder?: {
|
||||
show?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
footer?: ReactNode;
|
||||
children?: (props: DropzoneRenderProps) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -86,6 +91,8 @@ export function Dropzone({
|
||||
onDeleted,
|
||||
onUploaded,
|
||||
children,
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneProps) {
|
||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
@@ -393,6 +400,8 @@ export function Dropzone({
|
||||
autoUpload,
|
||||
flow,
|
||||
},
|
||||
onClick,
|
||||
footer,
|
||||
};
|
||||
|
||||
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
||||
@@ -404,6 +413,8 @@ const DropzoneInner = ({
|
||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
const Placeholder = showPlaceholder && (
|
||||
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
|
||||
@@ -438,9 +449,11 @@ const DropzoneInner = ({
|
||||
file={file}
|
||||
handleUpload={uploadHandler}
|
||||
handleDelete={deleteFile}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
{flow === "end" && Placeholder}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,26 +499,43 @@ type PreviewProps = {
|
||||
file: FileState;
|
||||
handleUpload: (file: FileState) => Promise<void>;
|
||||
handleDelete: (file: FileState) => Promise<void>;
|
||||
onClick?: (file: FileState) => void;
|
||||
};
|
||||
const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
|
||||
const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => {
|
||||
const dropdownItems = [
|
||||
file.state === "uploaded" &&
|
||||
typeof file.body === "string" && {
|
||||
label: "Open",
|
||||
icon: TbExternalLink,
|
||||
onClick: () => {
|
||||
window.open(file.body as string, "_blank");
|
||||
},
|
||||
},
|
||||
["initial", "uploaded"].includes(file.state) && {
|
||||
label: "Delete",
|
||||
destructive: true,
|
||||
icon: TbTrash,
|
||||
onClick: () => handleDelete(file),
|
||||
},
|
||||
["initial", "pending"].includes(file.state) && {
|
||||
label: "Upload",
|
||||
icon: TbUpload,
|
||||
onClick: () => handleUpload(file),
|
||||
},
|
||||
];
|
||||
] satisfies (DropdownItem | boolean)[];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-[49%] md:w-60 flex flex-col border border-muted relative",
|
||||
"w-[49%] md:w-60 flex flex-col border border-muted relative hover:bg-primary/5 cursor-pointer transition-colors",
|
||||
file.state === "failed" && "border-red-500 bg-red-200/20",
|
||||
file.state === "deleting" && "opacity-70",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick(file);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Dropdown items={dropdownItems} position="bottom-end">
|
||||
@@ -531,7 +561,7 @@ const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
|
||||
<p className="truncate select-text">{file.name}</p>
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate select-text">{file.type}</span>
|
||||
<span>{(file.size / 1024).toFixed(1)} KB</span>
|
||||
<span>{formatNumber.fileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,20 @@ import type { Api } from "bknd/client";
|
||||
import type { RepoQueryIn } from "data";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { type ReactNode, createContext, useContext, useId } from "react";
|
||||
import { useApi, useApiQuery, useInvalidate } from "ui/client";
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useId,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useApi, useApiInfiniteQuery, 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";
|
||||
import { useInViewport } from "@mantine/hooks";
|
||||
|
||||
export type DropzoneContainerProps = {
|
||||
children?: ReactNode;
|
||||
@@ -36,30 +45,32 @@ export function DropzoneContainer({
|
||||
const api = useApi();
|
||||
const invalidate = useInvalidate();
|
||||
const baseUrl = api.baseUrl;
|
||||
const defaultQuery = {
|
||||
limit: query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50,
|
||||
const pageSize = query?.limit ?? props.maxItems ?? 50;
|
||||
const defaultQuery = (page: number) => ({
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
sort: "-id",
|
||||
};
|
||||
});
|
||||
const entity_name = (media?.entity_name ?? "media") as "media";
|
||||
//console.log("dropzone:baseUrl", baseUrl);
|
||||
|
||||
const selectApi = (api: Api) =>
|
||||
const selectApi = (api: Api, page: number) =>
|
||||
entity
|
||||
? api.data.readManyByReference(entity.name, entity.id, entity.field, {
|
||||
...defaultQuery,
|
||||
...query,
|
||||
where: {
|
||||
reference: `${entity.name}.${entity.field}`,
|
||||
entity_id: entity.id,
|
||||
...query?.where,
|
||||
},
|
||||
...defaultQuery(page),
|
||||
})
|
||||
: api.data.readMany(entity_name, {
|
||||
...defaultQuery,
|
||||
...query,
|
||||
...defaultQuery(page),
|
||||
});
|
||||
|
||||
const $q = useApiQuery(selectApi, { enabled: initialItems !== false && !initialItems });
|
||||
const $q = useApiInfiniteQuery(selectApi, {});
|
||||
|
||||
const getUploadInfo = useEvent((file) => {
|
||||
const url = entity
|
||||
@@ -88,27 +99,62 @@ export function DropzoneContainer({
|
||||
|
||||
const key = id + JSON.stringify(_initialItems);
|
||||
return (
|
||||
<Dropzone
|
||||
key={id + key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
onUploaded={refresh}
|
||||
onDeleted={refresh}
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
{...props}
|
||||
>
|
||||
{children
|
||||
? (props) => (
|
||||
<DropzoneContainerContext.Provider value={props}>
|
||||
{children}
|
||||
</DropzoneContainerContext.Provider>
|
||||
)
|
||||
: undefined}
|
||||
</Dropzone>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Dropzone
|
||||
key={id + key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
onUploaded={refresh}
|
||||
onDeleted={refresh}
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={$q._data?.[0]?.body.meta.count ?? 0}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children
|
||||
? (props) => (
|
||||
<DropzoneContainerContext.Provider value={props}>
|
||||
{children}
|
||||
</DropzoneContainerContext.Provider>
|
||||
)
|
||||
: undefined}
|
||||
</Dropzone>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Footer = ({ items = 0, length = 0, onFirstVisible }) => {
|
||||
const { ref, inViewport } = useInViewport();
|
||||
const [visible, setVisible] = useState(0);
|
||||
const lastItemsCount = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewport && items > lastItemsCount.current) {
|
||||
lastItemsCount.current = items;
|
||||
setVisible((v) => v + 1);
|
||||
onFirstVisible();
|
||||
}
|
||||
}, [inViewport]);
|
||||
const _len = length - items;
|
||||
if (_len <= 0) return null;
|
||||
|
||||
return new Array(Math.max(length - items, 0)).fill(0).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={i === 0 ? ref : undefined}
|
||||
className="w-[49%] md:w-60 bg-muted aspect-square"
|
||||
>
|
||||
{i === 0 ? (inViewport ? `load ${visible}` : "first") : "other"}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export function useDropzone() {
|
||||
return useContext(DropzoneContainerContext);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isDebug } from "core";
|
||||
|
||||
export const useEvent = <Fn>(fn: Fn): Fn => {
|
||||
if (isDebug()) {
|
||||
console.warn("useEvent() is deprecated");
|
||||
//console.warn("useEvent() is deprecated");
|
||||
}
|
||||
return fn;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createMantineTheme(scheme: "light" | "dark"): {
|
||||
};
|
||||
|
||||
const input =
|
||||
"!bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:border-zinc-500";
|
||||
"!bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:!border-zinc-500";
|
||||
|
||||
return {
|
||||
theme: createTheme({
|
||||
|
||||
@@ -6,6 +6,8 @@ import { CreateModal } from "ui/modules/data/components/schema/create-modal/Crea
|
||||
import { DebugModal } from "./debug/DebugModal";
|
||||
import { SchemaFormModal } from "./debug/SchemaFormModal";
|
||||
import { TestModal } from "./debug/TestModal";
|
||||
import { scaleFadeIn } from "ui/modals/transitions";
|
||||
import { MediaInfoModal } from "ui/modals/media/MediaInfoModal";
|
||||
|
||||
const modals = {
|
||||
test: TestModal,
|
||||
@@ -13,6 +15,7 @@ const modals = {
|
||||
form: SchemaFormModal,
|
||||
overlay: OverlayModal,
|
||||
dataCreate: CreateModal,
|
||||
mediaInfo: MediaInfoModal,
|
||||
};
|
||||
|
||||
declare module "@mantine/modals" {
|
||||
@@ -38,8 +41,14 @@ function open<Modal extends keyof typeof modals>(
|
||||
...cmpModalProps,
|
||||
modal,
|
||||
innerProps,
|
||||
};
|
||||
openContextModal(props);
|
||||
} as any;
|
||||
openContextModal({
|
||||
transitionProps: {
|
||||
transition: scaleFadeIn,
|
||||
duration: 300,
|
||||
},
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
close: () => close(modal),
|
||||
closeAll: $modals.closeAll,
|
||||
|
||||
177
app/src/ui/modals/media/MediaInfoModal.tsx
Normal file
177
app/src/ui/modals/media/MediaInfoModal.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { TbCheck, TbCopy } from "react-icons/tb";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { ButtonLink } from "ui/components/buttons/Button";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
|
||||
import { JsonViewer } from "ui";
|
||||
|
||||
export type MediaInfoModalProps = {
|
||||
file: FileState;
|
||||
};
|
||||
|
||||
export function MediaInfoModal({
|
||||
context,
|
||||
id,
|
||||
innerProps: { file },
|
||||
}: ContextModalProps<MediaInfoModalProps>) {
|
||||
const {
|
||||
config: { entity_name, basepath },
|
||||
} = useBkndMedia();
|
||||
const $q = useEntityQuery(entity_name as "media", undefined, {
|
||||
where: {
|
||||
path: file.path,
|
||||
},
|
||||
});
|
||||
const close = () => context.closeModal(id);
|
||||
const data = $q.data?.[0];
|
||||
const origin = window.location.origin;
|
||||
const entity = data?.reference ? data?.reference.split(".")[0] : undefined;
|
||||
const entityUrl = entity
|
||||
? "/data" + routes.data.entity.edit(entity, data?.entity_id!)
|
||||
: undefined;
|
||||
const mediaUrl = data?.path
|
||||
? "/data" + routes.data.entity.edit(entity_name, data?.id!)
|
||||
: undefined;
|
||||
//const assetUrl = data?.path ? origin + basepath + "/file/" + data?.path : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
|
||||
{/* @ts-ignore */}
|
||||
<Media.Preview file={file} className="max-h-[70dvh]" controls muted />
|
||||
</div>
|
||||
<div className="w-full md:!w-[300px] flex flex-col">
|
||||
<Item title="ID" value={data?.id} copyValue={origin + mediaUrl} first>
|
||||
{mediaUrl && (
|
||||
<ButtonLink
|
||||
href={mediaUrl!}
|
||||
size="small"
|
||||
className="py-1.5 px-2 !leading-none font-mono"
|
||||
onClick={close}
|
||||
>
|
||||
#{String(data?.id)}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</Item>
|
||||
<Item title="Path" value={data?.path} />
|
||||
<Item title="Mime Type" value={data?.mime_type} />
|
||||
<Item
|
||||
title="Size"
|
||||
value={data?.size && formatNumber.fileSize(data.size, 1)}
|
||||
copyValue={data?.size}
|
||||
/>
|
||||
<Item title="Etag" value={data?.etag} />
|
||||
<Item title="Entity" copyValue={origin + entityUrl}>
|
||||
{entityUrl && (
|
||||
<ButtonLink
|
||||
href={entityUrl!}
|
||||
size="small"
|
||||
className="py-1.5 px-2 !leading-none font-mono"
|
||||
onClick={close}
|
||||
>
|
||||
{data?.reference} #{data?.entity_id}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</Item>
|
||||
<Item
|
||||
title="Modified At"
|
||||
value={data?.modified_at && datetimeStringLocal(data?.modified_at)}
|
||||
copyValue={data?.modified_at}
|
||||
/>
|
||||
<Item title="Metadata" value={data?.metadata} copyable={false}>
|
||||
{data?.metadata && (
|
||||
<JsonViewer
|
||||
json={data?.metadata}
|
||||
expand={2}
|
||||
showCopy
|
||||
className="w-full text-sm bg-primary/2 pt-2.5 rounded-lg"
|
||||
copyIconProps={{
|
||||
className: "size-6 opacity-20 group-hover:opacity-100 transition-all",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Item = ({
|
||||
title,
|
||||
children,
|
||||
value,
|
||||
first,
|
||||
copyable = true,
|
||||
copyValue,
|
||||
}: {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
value?: any;
|
||||
first?: boolean;
|
||||
copyable?: boolean;
|
||||
copyValue?: any;
|
||||
}) => {
|
||||
const cb = useClipboard();
|
||||
|
||||
const is_null = !children && (value === null || typeof value === "undefined");
|
||||
const can_copy = copyable && !is_null && cb.copy !== undefined;
|
||||
const _value = value
|
||||
? typeof value === "object" && !is_null
|
||||
? JSON.stringify(value)
|
||||
: String(value)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1 py-3 pl-5 pr-3 group",
|
||||
!first && "border-t border-muted",
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-bold opacity-50">{autoFormatString(title)}</div>
|
||||
<div className="flex flex-row gap-1 justify-between items-center">
|
||||
{children ?? (
|
||||
<div className={twMerge("font-mono truncate", is_null && "opacity-30")}>
|
||||
{is_null ? "null" : _value}
|
||||
</div>
|
||||
)}
|
||||
{can_copy && (
|
||||
<IconButton
|
||||
Icon={cb.copied ? TbCheck : TbCopy}
|
||||
className={twMerge(
|
||||
"size-6 opacity-20 group-hover:opacity-100 transition-all",
|
||||
cb.copied && "text-success-foreground opacity-100",
|
||||
)}
|
||||
onClick={() => cb.copy(copyValue ? copyValue : value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MediaInfoModal.defaultTitle = undefined;
|
||||
MediaInfoModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
size: "auto",
|
||||
//size: "90%",
|
||||
centered: true,
|
||||
styles: {
|
||||
content: {
|
||||
overflowY: "initial !important",
|
||||
},
|
||||
},
|
||||
classNames: {
|
||||
root: "bknd-admin w-full max-w-xl",
|
||||
content: "overflow-hidden",
|
||||
title: "font-bold !text-md",
|
||||
body: "max-h-inherit !p-0",
|
||||
},
|
||||
};
|
||||
7
app/src/ui/modals/transitions.ts
Normal file
7
app/src/ui/modals/transitions.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { MantineTransition } from "@mantine/core";
|
||||
|
||||
export const scaleFadeIn: MantineTransition = {
|
||||
in: { opacity: 1, transform: "scale(1)" },
|
||||
out: { opacity: 0, transform: "scale(0.9)" },
|
||||
transitionProperty: "transform, opacity",
|
||||
};
|
||||
@@ -15,12 +15,13 @@ import { type ComponentProps, Suspense } from "react";
|
||||
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 { type FileState, Media } from "ui/elements";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import { bkndModals } from "ui/modals";
|
||||
|
||||
// simplify react form types 🤦
|
||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
||||
@@ -237,6 +238,11 @@ function EntityMediaFormField({
|
||||
});
|
||||
|
||||
const key = JSON.stringify([entity, entityId, field.name, value.length]);
|
||||
const onClick = (file: FileState) => {
|
||||
bkndModals.open(bkndModals.ids.mediaInfo, {
|
||||
file,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
@@ -245,6 +251,7 @@ function EntityMediaFormField({
|
||||
key={key}
|
||||
maxItems={field.getMaxItems()}
|
||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||
onClick={onClick}
|
||||
entity={{
|
||||
name: entity.name,
|
||||
id: entityId,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Media } from "ui/elements";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { useLocation } from "wouter";
|
||||
import { bkndModals } from "ui/modals";
|
||||
|
||||
export function MediaIndex() {
|
||||
const { app, config } = useBknd();
|
||||
const { config } = useBknd();
|
||||
const [, navigate] = useLocation();
|
||||
useBrowserTitle(["Media"]);
|
||||
|
||||
@@ -25,10 +26,16 @@ export function MediaIndex() {
|
||||
);
|
||||
}
|
||||
|
||||
const onClick = (file: FileState) => {
|
||||
bkndModals.open(bkndModals.ids.mediaInfo, {
|
||||
file,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone />
|
||||
<Media.Dropzone onClick={onClick} />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function DropzoneElementTest() {
|
||||
</Media.Dropzone>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/*<div>
|
||||
<b>Dropzone User Avatar 1 (overwrite)</b>
|
||||
<Media.Dropzone
|
||||
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||
@@ -36,7 +36,7 @@ export default function DropzoneElementTest() {
|
||||
<div>
|
||||
<b>Dropzone Container blank w/ query</b>
|
||||
<Media.Dropzone query={{ limit: 2 }} />
|
||||
</div>
|
||||
</div>*/}
|
||||
|
||||
<div>
|
||||
<b>Dropzone Container blank</b>
|
||||
|
||||
Reference in New Issue
Block a user