diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index c2239e4..c94c4bb 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -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"; diff --git a/app/src/core/utils/numbers.ts b/app/src/core/utils/numbers.ts index 33394f6..e9b458b 100644 --- a/app/src/core/utils/numbers.ts +++ b/app/src/core/utils/numbers.ts @@ -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]; + }, +}; diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 0ca8162..6c453af 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -331,4 +331,15 @@ export class FetchPromise> implements Promise { Boolean, ); } + + toString() { + return this.key({ search: true }); + } + + toJSON() { + return { + url: this.request.url, + method: this.request.method, + }; + } } diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index f832adc..72feeeb 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -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) => unknown = (data: ResponseObject) => Data, +>( + fn: (api: Api, page: number) => FetchPromise, + 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) => infer R ? R : Data; + + // @ts-ignore + const swr = useSWRInfinite( + (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(); diff --git a/app/src/ui/components/code/JsonViewer.tsx b/app/src/ui/components/code/JsonViewer.tsx index 2d76d21..923846b 100644 --- a/app/src/ui/components/code/JsonViewer.tsx +++ b/app/src/ui/components/code/JsonViewer.tsx @@ -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 (
{showContext && ( -
+
{(title || size) && (
{title && {title}} {size && ({size} Bytes)} @@ -36,7 +38,7 @@ export const JsonViewer = ({ )} {showCopy && (
- +
)}
diff --git a/app/src/ui/components/overlay/Dropdown.tsx b/app/src/ui/components/overlay/Dropdown.tsx index 5d2ff4f..78ed2ff 100644 --- a/app/src/ui/components/overlay/Dropdown.tsx +++ b/app/src/ui/components/overlay/Dropdown.tsx @@ -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 && ( -
{title}
+
+ {title} +
)} {menuItems.map((item, i) => - itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }), + itemRenderer(item, { + key: i, + onClick: (e) => { + e.stopPropagation(); + internalOnClickItem(item); + }, + }), )}
)} diff --git a/app/src/ui/components/wouter/Link.tsx b/app/src/ui/components/wouter/Link.tsx index c1ca181..116555c 100644 --- a/app/src/ui/components/wouter/Link.tsx +++ b/app/src/ui/components/wouter/Link.tsx @@ -88,6 +88,7 @@ export function Link({ } const wouterOnClick = (e: any) => { + onClick?.(e); // prepared for view transition /*if (props.transition !== false) { e.preventDefault(); diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 952bca6..9301dea 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -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; openFileInput: () => void; }; + onClick?: (file: FileState) => void; + footer?: ReactNode; dropzoneProps: Pick; }; @@ -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(initialItems); const [uploading, setUploading] = useState(false); @@ -393,6 +400,8 @@ export function Dropzone({ autoUpload, flow, }, + onClick, + footer, }; return children ? children(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 && ( @@ -438,9 +449,11 @@ const DropzoneInner = ({ file={file} handleUpload={uploadHandler} handleDelete={deleteFile} + onClick={onClick} /> ))} {flow === "end" && Placeholder} + {footer}
@@ -486,26 +499,43 @@ type PreviewProps = { file: FileState; handleUpload: (file: FileState) => Promise; handleDelete: (file: FileState) => Promise; + 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 (
{ + if (onClick) { + onClick(file); + } + }} >
@@ -531,7 +561,7 @@ const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {

{file.name}

{file.type} - {(file.size / 1024).toFixed(1)} KB + {formatNumber.fileSize(file.size)}
diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index fce4049..d2ce21e 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -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 ( - - {children - ? (props) => ( - - {children} - - ) - : undefined} - +
+ $q.setSize($q.size + 1)} + /> + } + {...props} + > + {children + ? (props) => ( + + {children} + + ) + : undefined} + +
); } +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) => ( +
+ {i === 0 ? (inViewport ? `load ${visible}` : "first") : "other"} +
+ )); +}; + export function useDropzone() { return useContext(DropzoneContainerContext); } diff --git a/app/src/ui/hooks/use-event.ts b/app/src/ui/hooks/use-event.ts index 23f8130..e55baca 100644 --- a/app/src/ui/hooks/use-event.ts +++ b/app/src/ui/hooks/use-event.ts @@ -9,7 +9,7 @@ import { isDebug } from "core"; export const useEvent = (fn: Fn): Fn => { if (isDebug()) { - console.warn("useEvent() is deprecated"); + //console.warn("useEvent() is deprecated"); } return fn; }; diff --git a/app/src/ui/lib/mantine/theme.ts b/app/src/ui/lib/mantine/theme.ts index 0f6bfce..8127b86 100644 --- a/app/src/ui/lib/mantine/theme.ts +++ b/app/src/ui/lib/mantine/theme.ts @@ -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({ diff --git a/app/src/ui/modals/index.tsx b/app/src/ui/modals/index.tsx index 03071e3..ee2fa40 100644 --- a/app/src/ui/modals/index.tsx +++ b/app/src/ui/modals/index.tsx @@ -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( ...cmpModalProps, modal, innerProps, - }; - openContextModal(props); + } as any; + openContextModal({ + transitionProps: { + transition: scaleFadeIn, + duration: 300, + }, + ...props, + }); return { close: () => close(modal), closeAll: $modals.closeAll, diff --git a/app/src/ui/modals/media/MediaInfoModal.tsx b/app/src/ui/modals/media/MediaInfoModal.tsx new file mode 100644 index 0000000..744ea60 --- /dev/null +++ b/app/src/ui/modals/media/MediaInfoModal.tsx @@ -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) { + 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 ( +
+
+ {/* @ts-ignore */} + +
+
+ + {mediaUrl && ( + + #{String(data?.id)} + + )} + + + + + + + {entityUrl && ( + + {data?.reference} #{data?.entity_id} + + )} + + + + {data?.metadata && ( + + )} + +
+
+ ); +} + +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 ( +
+
{autoFormatString(title)}
+
+ {children ?? ( +
+ {is_null ? "null" : _value} +
+ )} + {can_copy && ( + cb.copy(copyValue ? copyValue : value)} + /> + )} +
+
+ ); +}; + +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", + }, +}; diff --git a/app/src/ui/modals/transitions.ts b/app/src/ui/modals/transitions.ts new file mode 100644 index 0000000..dee6cc7 --- /dev/null +++ b/app/src/ui/modals/transitions.ts @@ -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", +}; diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 1551551..b373d6b 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -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; @@ -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 ( @@ -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, diff --git a/app/src/ui/routes/media/media.index.tsx b/app/src/ui/routes/media/media.index.tsx index 9c32791..16356ad 100644 --- a/app/src/ui/routes/media/media.index.tsx +++ b/app/src/ui/routes/media/media.index.tsx @@ -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 (
- +
); diff --git a/app/src/ui/routes/test/tests/dropzone-element-test.tsx b/app/src/ui/routes/test/tests/dropzone-element-test.tsx index 68ccbc3..cf70a7b 100644 --- a/app/src/ui/routes/test/tests/dropzone-element-test.tsx +++ b/app/src/ui/routes/test/tests/dropzone-element-test.tsx @@ -19,7 +19,7 @@ export default function DropzoneElementTest() { -
+ {/*
Dropzone User Avatar 1 (overwrite) Dropzone Container blank w/ query -
+
*/}
Dropzone Container blank