From 9407f3d212c034f86ba1a5f59abaaaaff5882eed Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 09:21:58 +0100 Subject: [PATCH 1/3] add image dimension detection for most common formats --- app/__test__/_assets/image.jpg | Bin 0 -> 16796 bytes app/__test__/core/utils.spec.ts | 124 +++++---- app/__test__/media/mime-types.spec.ts | 8 +- app/src/Api.ts | 4 + app/src/core/utils/file.ts | 239 ++++++++++++++++++ app/src/core/utils/reqres.ts | 180 ------------- app/src/core/utils/runtime.ts | 6 + app/src/media/AppMedia.ts | 9 +- app/src/media/storage/Storage.ts | 20 +- .../storage/adapters/StorageS3Adapter.ts | 3 +- 10 files changed, 352 insertions(+), 241 deletions(-) create mode 100644 app/__test__/_assets/image.jpg create mode 100644 app/src/core/utils/file.ts diff --git a/app/__test__/_assets/image.jpg b/app/__test__/_assets/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0f7890d93503af3b3a5e3f9b1de33b034c0341d6 GIT binary patch literal 16796 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 { @@ -75,57 +76,6 @@ describe("Core Utils", async () => { const result3 = utils.encodeSearch(obj3, { encode: true }); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); }); - - describe("guards", () => { - const types = { - blob: new Blob(), - file: new File([""], "file.txt"), - stream: new ReadableStream(), - arrayBuffer: new ArrayBuffer(10), - arrayBufferView: new Uint8Array(new ArrayBuffer(10)), - }; - - const fns = [ - [utils.isReadableStream, "stream"], - [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], - [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], - [utils.isArrayBuffer, "arrayBuffer"], - [utils.isArrayBufferView, "arrayBufferView"], - ] as const; - - const additional = [0, 0.0, "", null, undefined, {}, []]; - - for (const [fn, type, _to_test] of fns) { - test(`is${ucFirst(type)}`, () => { - const to_test = _to_test ?? (Object.keys(types) as string[]); - for (const key of to_test) { - const value = types[key as keyof typeof types]; - const result = fn(value); - expect(result).toBe(key === type); - } - - for (const value of additional) { - const result = fn(value); - expect(result).toBe(false); - } - }); - } - }); - - test("getContentName", () => { - const name = "test.json"; - const text = "attachment; filename=" + name; - const headers = new Headers({ - "Content-Disposition": text, - }); - const request = new Request("http://example.com", { - headers, - }); - - expect(utils.getContentName(text)).toBe(name); - expect(utils.getContentName(headers)).toBe(name); - expect(utils.getContentName(request)).toBe(name); - }); }); describe("perf", async () => { @@ -246,6 +196,76 @@ describe("Core Utils", async () => { }); }); + describe("file", async () => { + describe("type guards", () => { + const types = { + blob: new Blob(), + file: new File([""], "file.txt"), + stream: new ReadableStream(), + arrayBuffer: new ArrayBuffer(10), + arrayBufferView: new Uint8Array(new ArrayBuffer(10)), + }; + + const fns = [ + [utils.isReadableStream, "stream"], + [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isArrayBuffer, "arrayBuffer"], + [utils.isArrayBufferView, "arrayBufferView"], + ] as const; + + const additional = [0, 0.0, "", null, undefined, {}, []]; + + for (const [fn, type, _to_test] of fns) { + test(`is${ucFirst(type)}`, () => { + const to_test = _to_test ?? (Object.keys(types) as string[]); + for (const key of to_test) { + const value = types[key as keyof typeof types]; + const result = fn(value); + expect(result).toBe(key === type); + } + + for (const value of additional) { + const result = fn(value); + expect(result).toBe(false); + } + }); + } + }); + + test("getContentName", () => { + const name = "test.json"; + const text = "attachment; filename=" + name; + const headers = new Headers({ + "Content-Disposition": text, + }); + const request = new Request("http://example.com", { + headers, + }); + + expect(utils.getContentName(text)).toBe(name); + expect(utils.getContentName(headers)).toBe(name); + expect(utils.getContentName(request)).toBe(name); + }); + + test.only("detectImageDimensions", async () => { + // wrong + // @ts-expect-error + expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow(); + + // successful ones + const getFile = (name: string): File => Bun.file(`${assetsPath}/${name}`) as any; + expect(await utils.detectImageDimensions(getFile("image.png"))).toEqual({ + width: 362, + height: 387, + }); + expect(await utils.detectImageDimensions(getFile("image.jpg"))).toEqual({ + width: 453, + height: 512, + }); + }); + }); + describe("dates", () => { test.only("formats local time", () => { expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index 6c51fab..37f29a8 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils"; describe("media/mime-types", () => { test("tiny resolves", () => { - const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]]; + const tests = [ + [".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"], + ] as const; for (const [ext, mime] of tests) { expect(tiny.guess(ext)).toBe(mime); @@ -69,7 +71,7 @@ describe("media/mime-types", () => { ["application/zip", "zip"], ["text/tab-separated-values", "tsv"], ["application/zip", "zip"], - ]; + ] as const; for (const [mime, ext] of tests) { expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext); @@ -86,7 +88,7 @@ describe("media/mime-types", () => { ["image.jpeg", "jpeg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"], - ]; + ] as const; for (const [filename, ext] of tests) { expect( diff --git a/app/src/Api.ts b/app/src/Api.ts index 70cbd13..593979e 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -78,6 +78,10 @@ export class Api { this.buildApis(); } + get fetcher() { + return this.options.fetcher ?? fetch; + } + get baseUrl() { return this.options.host ?? "http://localhost"; } diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts new file mode 100644 index 0000000..96970a7 --- /dev/null +++ b/app/src/core/utils/file.ts @@ -0,0 +1,239 @@ +import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; +import { randomString } from "core/utils/strings"; +import type { Context } from "hono"; +import { invariant } from "core/utils/runtime"; + +export function getContentName(request: Request): string | undefined; +export function getContentName(contentDisposition: string): string | undefined; +export function getContentName(headers: Headers): string | undefined; +export function getContentName(ctx: Headers | Request | string): string | undefined { + let c: string = ""; + + if (typeof ctx === "string") { + c = ctx; + } else if (ctx instanceof Headers) { + c = ctx.get("Content-Disposition") || ""; + } else if (ctx instanceof Request) { + c = ctx.headers.get("Content-Disposition") || ""; + } + + const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/); + return match ? match[2] : undefined; +} + +export function isReadableStream(value: unknown): value is ReadableStream { + return ( + typeof value === "object" && + value !== null && + typeof (value as ReadableStream).getReader === "function" + ); +} + +export function isBlob(value: unknown): value is Blob { + return ( + typeof value === "object" && + value !== null && + typeof (value as Blob).arrayBuffer === "function" && + typeof (value as Blob).type === "string" + ); +} + +export function isFile(value: unknown): value is File { + return ( + isBlob(value) && + typeof (value as File).name === "string" && + typeof (value as File).lastModified === "number" + ); +} + +export function isArrayBuffer(value: unknown): value is ArrayBuffer { + return ( + typeof value === "object" && + value !== null && + Object.prototype.toString.call(value) === "[object ArrayBuffer]" + ); +} + +export function isArrayBufferView(value: unknown): value is ArrayBufferView { + return typeof value === "object" && value !== null && ArrayBuffer.isView(value); +} + +const FILE_SIGNATURES: Record = { + "89504E47": "image/png", + FFD8FF: "image/jpeg", + "47494638": "image/gif", + "49492A00": "image/tiff", // Little Endian TIFF + "4D4D002A": "image/tiff", // Big Endian TIFF + "52494646????57454250": "image/webp", // WEBP (RIFF....WEBP) + "504B0304": "application/zip", + "25504446": "application/pdf", + "00000020667479706D70": "video/mp4", + "000001BA": "video/mpeg", + "000001B3": "video/mpeg", + "1A45DFA3": "video/webm", + "4F676753": "audio/ogg", + "494433": "audio/mpeg", // MP3 with ID3 header + FFF1: "audio/aac", + FFF9: "audio/aac", + "52494646????41564920": "audio/wav", + "52494646????57415645": "audio/wave", + "52494646????415550": "audio/aiff", +}; + +async function detectMimeType( + input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null, +): Promise { + if (!input) return; + + let buffer: Uint8Array; + + if (isReadableStream(input)) { + const reader = input.getReader(); + const { value } = await reader.read(); + if (!value) return; + buffer = new Uint8Array(value); + } else if (isBlob(input) || isFile(input)) { + buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer()); + } else if (isArrayBuffer(input)) { + buffer = new Uint8Array(input); + } else if (isArrayBufferView(input)) { + buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } else if (typeof input === "string") { + buffer = new TextEncoder().encode(input); + } else { + return; + } + + const hex = Array.from(buffer.slice(0, 12)) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + + for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) { + const regex = new RegExp("^" + signature.replace(/\?\?/g, "..")); + if (regex.test(hex)) return mime; + } + + return; +} + +export async function getFileFromContext(c: Context): Promise { + const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; + + if ( + contentType?.startsWith("multipart/form-data") || + contentType?.startsWith("application/x-www-form-urlencoded") + ) { + try { + const f = await c.req.formData(); + if ([...f.values()].length > 0) { + const v = [...f.values()][0]; + return await blobToFile(v); + } + } catch (e) { + console.warn("Error parsing form data", e); + } + } else { + try { + const blob = await c.req.blob(); + if (isFile(blob)) { + return blob; + } else if (isBlob(blob)) { + return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); + } + } catch (e) { + console.warn("Error parsing blob", e); + } + } + + throw new Error("No file found in request"); +} + +export async function getBodyFromContext(c: Context): Promise { + const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; + + if ( + !contentType?.startsWith("multipart/form-data") && + !contentType?.startsWith("application/x-www-form-urlencoded") + ) { + const body = c.req.raw.body; + if (body) { + return body; + } + } + + return getFileFromContext(c); +} + +type ImageDim = { width: number; height: number }; +export async function detectImageDimensions( + input: ArrayBuffer, + type: `image/${string}`, +): Promise; +export async function detectImageDimensions(input: File): Promise; +export async function detectImageDimensions( + input: File | ArrayBuffer, + _type?: `image/${string}`, +): Promise { + // Only process images + const is_file = isFile(input); + const type = is_file ? input.type : _type!; + + invariant(type && typeof type === "string" && type.startsWith("image/"), "type must be image/*"); + + const buffer = is_file ? await input.arrayBuffer() : input; + invariant(buffer.byteLength >= 128, "Buffer must be at least 128 bytes"); + + const dataView = new DataView(buffer); + + if (type === "image/jpeg") { + let offset = 2; + while (offset < dataView.byteLength) { + const marker = dataView.getUint16(offset); + offset += 2; + if (marker === 0xffc0 || marker === 0xffc2) { + return { + width: dataView.getUint16(offset + 5), + height: dataView.getUint16(offset + 3), + }; + } + offset += dataView.getUint16(offset); + } + } else if (type === "image/png") { + return { + width: dataView.getUint32(16), + height: dataView.getUint32(20), + }; + } else if (type === "image/gif") { + return { + width: dataView.getUint16(6), + height: dataView.getUint16(8), + }; + } else if (type === "image/tiff") { + const isLittleEndian = dataView.getUint16(0) === 0x4949; + const offset = dataView.getUint32(4, isLittleEndian); + const width = dataView.getUint32(offset + 18, isLittleEndian); + const height = dataView.getUint32(offset + 10, isLittleEndian); + return { width, height }; + } + + throw new Error("Unsupported image format"); +} + +export async function blobToFile( + blob: Blob | File | unknown, + overrides: FilePropertyBag & { name?: string } = {}, +): Promise { + if (isFile(blob)) return blob; + if (!isBlob(blob)) throw new Error("Not a Blob"); + + const type = isMimeType(overrides.type, ["application/octet-stream"]) + ? overrides.type + : await detectMimeType(blob); + const ext = type ? extension(type) : ""; + const name = overrides.name || [randomString(16), ext].filter(Boolean).join("."); + + return new File([blob], name, { + type: type || guess(name), + lastModified: Date.now(), + }); +} diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 9890334..3e9ec12 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -97,186 +97,6 @@ export function decodeSearch(str) { return out; } -export function isReadableStream(value: unknown): value is ReadableStream { - return ( - typeof value === "object" && - value !== null && - typeof (value as ReadableStream).getReader === "function" - ); -} - -export function isBlob(value: unknown): value is Blob { - return ( - typeof value === "object" && - value !== null && - typeof (value as Blob).arrayBuffer === "function" && - typeof (value as Blob).type === "string" - ); -} - -export function isFile(value: unknown): value is File { - return ( - isBlob(value) && - typeof (value as File).name === "string" && - typeof (value as File).lastModified === "number" - ); -} - -export function isArrayBuffer(value: unknown): value is ArrayBuffer { - return ( - typeof value === "object" && - value !== null && - Object.prototype.toString.call(value) === "[object ArrayBuffer]" - ); -} - -export function isArrayBufferView(value: unknown): value is ArrayBufferView { - return typeof value === "object" && value !== null && ArrayBuffer.isView(value); -} - -export function getContentName(request: Request): string | undefined; -export function getContentName(contentDisposition: string): string | undefined; -export function getContentName(headers: Headers): string | undefined; -export function getContentName(ctx: Headers | Request | string): string | undefined { - let c: string = ""; - - if (typeof ctx === "string") { - c = ctx; - } else if (ctx instanceof Headers) { - c = ctx.get("Content-Disposition") || ""; - } else if (ctx instanceof Request) { - c = ctx.headers.get("Content-Disposition") || ""; - } - - const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/); - return match ? match[2] : undefined; -} - -const FILE_SIGNATURES: Record = { - "89504E47": "image/png", - FFD8FF: "image/jpeg", - "47494638": "image/gif", - "49492A00": "image/tiff", // Little Endian TIFF - "4D4D002A": "image/tiff", // Big Endian TIFF - "52494646????57454250": "image/webp", // WEBP (RIFF....WEBP) - "504B0304": "application/zip", - "25504446": "application/pdf", - "00000020667479706D70": "video/mp4", - "000001BA": "video/mpeg", - "000001B3": "video/mpeg", - "1A45DFA3": "video/webm", - "4F676753": "audio/ogg", - "494433": "audio/mpeg", // MP3 with ID3 header - FFF1: "audio/aac", - FFF9: "audio/aac", - "52494646????41564920": "audio/wav", - "52494646????57415645": "audio/wave", - "52494646????415550": "audio/aiff", -}; - -async function detectMimeType( - input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null, -): Promise { - if (!input) return; - - let buffer: Uint8Array; - - if (isReadableStream(input)) { - const reader = input.getReader(); - const { value } = await reader.read(); - if (!value) return; - buffer = new Uint8Array(value); - } else if (isBlob(input) || isFile(input)) { - buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer()); - } else if (isArrayBuffer(input)) { - buffer = new Uint8Array(input); - } else if (isArrayBufferView(input)) { - buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength); - } else if (typeof input === "string") { - buffer = new TextEncoder().encode(input); - } else { - return; - } - - const hex = Array.from(buffer.slice(0, 12)) - .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) - .join(""); - - for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) { - const regex = new RegExp("^" + signature.replace(/\?\?/g, "..")); - if (regex.test(hex)) return mime; - } - - return; -} - -export async function blobToFile( - blob: Blob | File | unknown, - overrides: FilePropertyBag & { name?: string } = {}, -): Promise { - if (isFile(blob)) return blob; - if (!isBlob(blob)) throw new Error("Not a Blob"); - - const type = isMimeType(overrides.type, ["application/octet-stream"]) - ? overrides.type - : await detectMimeType(blob); - const ext = type ? extension(type) : ""; - const name = overrides.name || [randomString(16), ext].filter(Boolean).join("."); - - return new File([blob], name, { - type: type || guess(name), - lastModified: Date.now(), - }); -} - -export async function getFileFromContext(c: Context): Promise { - const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; - - if ( - contentType?.startsWith("multipart/form-data") || - contentType?.startsWith("application/x-www-form-urlencoded") - ) { - try { - const f = await c.req.formData(); - if ([...f.values()].length > 0) { - const v = [...f.values()][0]; - return await blobToFile(v); - } - } catch (e) { - console.warn("Error parsing form data", e); - } - } else { - try { - const blob = await c.req.blob(); - if (isFile(blob)) { - return blob; - } else if (isBlob(blob)) { - return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); - } - } catch (e) { - console.warn("Error parsing blob", e); - } - } - - throw new Error("No file found in request"); -} - -export async function getBodyFromContext(c: Context): Promise { - const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; - - if ( - !contentType?.startsWith("multipart/form-data") && - !contentType?.startsWith("application/x-www-form-urlencoded") - ) { - const body = c.req.raw.body; - if (body) { - return body; - } - } - - return getFileFromContext(c); -} - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status // biome-ignore lint/suspicious/noConstEnum: export const enum HttpStatus { diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 3e1bfa1..b90f670 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -47,3 +47,9 @@ export function isNode() { return false; } } + +export function invariant(condition: boolean | any, message: string) { + if (!condition) { + throw new Error(message); + } +} diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index f71ef6a..4c73b9d 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -75,13 +75,20 @@ export class AppMedia extends Module { return this._storage!; } - uploadedEventDataToMediaPayload(info: FileUploadedEventData) { + uploadedEventDataToMediaPayload(info: FileUploadedEventData): MediaFieldSchema { + const metadata: any = {}; + if (info.meta.width && info.meta.height) { + metadata.width = info.meta.width; + metadata.height = info.meta.height; + } + return { path: info.name, mime_type: info.meta.type, size: info.meta.size, etag: info.etag, modified_at: new Date(), + metadata, }; } diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 718d8b1..2df3451 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,8 +1,9 @@ import { type EmitsEvents, EventManager } from "core/events"; -import { type TSchema, isFile } from "core/utils"; +import { type TSchema, isFile, detectImageDimensions } from "core/utils"; import { isMimeType } from "media/storage/mime-types-tiny"; import * as StorageEvents from "./events"; import type { FileUploadedEventData } from "./events"; +import { $console } from "core"; export type FileListObject = { key: string; @@ -10,7 +11,7 @@ export type FileListObject = { size: number; }; -export type FileMeta = { type: string; size: number }; +export type FileMeta = { type: string; size: number; width?: number; height?: number }; export type FileBody = ReadableStream | File; export type FileUploadPayload = { name: string; @@ -102,7 +103,7 @@ export class Storage implements EmitsEvents { } // try to get better meta info - if (!isMimeType(info?.meta.type, ["application/octet-stream", "application/json"])) { + if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) { const meta = await this.#adapter.getObjectMeta(name); if (!meta) { throw new Error("Failed to get object meta"); @@ -110,6 +111,19 @@ export class Storage implements EmitsEvents { info.meta = meta; } + // try to get width/height for images + if (info.meta.type.startsWith("image") && (!info.meta.width || !info.meta.height)) { + try { + const dim = await detectImageDimensions(file as File); + info.meta = { + ...info.meta, + ...dim, + }; + } catch (e) { + $console.warn("Failed to get image dimensions", e); + } + } + const eventData = { file, ...info, diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index 37c651d..960b73d 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/StorageS3Adapter.ts @@ -7,7 +7,7 @@ import type { PutObjectRequest, } from "@aws-sdk/client-s3"; import { AwsClient, isDebug } from "core"; -import { type Static, Type, isFile, parse, pickHeaders, pickHeaders2 } from "core/utils"; +import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils"; import { transform } from "lodash-es"; import type { FileBody, FileListObject, StorageAdapter } from "../Storage"; @@ -178,7 +178,6 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { const res = await this.fetch(url, { method: "GET", headers: pickHeaders2(headers, [ - "range", "if-none-match", "accept-encoding", "accept", From f6a511d99812bb30a0aefc4b0c893b30e67dbf47 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 09:23:14 +0100 Subject: [PATCH 2/3] add media detail dialog and infinite loading --- app/src/core/utils/index.ts | 1 + app/src/core/utils/numbers.ts | 11 ++ app/src/modules/ModuleApi.ts | 11 ++ app/src/ui/client/api/use-api.ts | 48 ++++- app/src/ui/components/code/JsonViewer.tsx | 6 +- app/src/ui/components/overlay/Dropdown.tsx | 22 ++- app/src/ui/components/wouter/Link.tsx | 1 + app/src/ui/elements/media/Dropzone.tsx | 42 ++++- .../ui/elements/media/DropzoneContainer.tsx | 100 +++++++--- app/src/ui/hooks/use-event.ts | 2 +- app/src/ui/lib/mantine/theme.ts | 2 +- app/src/ui/modals/index.tsx | 13 +- app/src/ui/modals/media/MediaInfoModal.tsx | 177 ++++++++++++++++++ app/src/ui/modals/transitions.ts | 7 + .../ui/modules/data/components/EntityForm.tsx | 9 +- app/src/ui/routes/media/media.index.tsx | 13 +- .../test/tests/dropzone-element-test.tsx | 4 +- 17 files changed, 419 insertions(+), 50 deletions(-) create mode 100644 app/src/ui/modals/media/MediaInfoModal.tsx create mode 100644 app/src/ui/modals/transitions.ts 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 From 7facef47dab3e13fdbde8170eac829affa0fb5fa Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 09:57:31 +0100 Subject: [PATCH 3/3] fix media styling on mobile --- app/src/ui/elements/media/Dropzone.tsx | 6 +-- .../ui/elements/media/DropzoneContainer.tsx | 52 +++++++++---------- app/src/ui/layouts/AppShell/AppShell.tsx | 2 +- app/src/ui/layouts/AppShell/Header.tsx | 2 +- .../ui/routes/data/data.schema.$entity.tsx | 2 +- 5 files changed, 31 insertions(+), 33 deletions(-) diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 9301dea..57d6c48 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -463,7 +463,7 @@ const DropzoneInner = ({ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => { return (
{text} @@ -527,7 +527,7 @@ const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => return (
/>
)} -
+
- $q.setSize($q.size + 1)} - /> - } - {...props} - > - {children - ? (props) => ( - - {children} - - ) - : undefined} - -
+ $q.setSize($q.size + 1)} + /> + } + {...props} + > + {children + ? (props) => ( + + {children} + + ) + : undefined} + ); } diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 16cfc8b..5d61bbf 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -99,7 +99,7 @@ export function Main({ children }) { export function Sidebar({ children }) { const open = appShellStore((store) => store.sidebarOpen); const close = appShellStore((store) => store.closeSidebar); - const ref = useClickOutside(close, null, [document.getElementById("header")]); + const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]); const [location] = useLocation(); const closeHandler = () => { diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index a942171..4093094 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -107,7 +107,7 @@ export function HeaderNavigation() { function SidebarToggler() { const toggle = appShellStore((store) => store.toggleSidebar); const open = appShellStore((store) => store.sidebarOpen); - return ; + return ; } export function Header({ hasSidebar = true }) { diff --git a/app/src/ui/routes/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index 8b493dc..e93d7f2 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -103,7 +103,7 @@ export function DataSchemaEntity({ params }) { path={[{ label: "Schema", href: "/" }, { label: entity.label }]} backTo="/" /> - +