diff --git a/app/__test__/_assets/image.jpg b/app/__test__/_assets/image.jpg new file mode 100644 index 0000000..0f7890d Binary files /dev/null and b/app/__test__/_assets/image.jpg differ diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index db3c967..15428bf 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { Perf, datetimeStringUTC, isBlob, ucFirst } from "../../src/core/utils"; +import { Perf, ucFirst } from "../../src/core/utils"; import * as utils from "../../src/core/utils"; +import { assetsPath } from "../helper"; async function wait(ms: number) { return new Promise((resolve) => { @@ -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/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/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", 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..57d6c48 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}
@@ -450,7 +463,7 @@ const DropzoneInner = ({ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => { return (
{text} @@ -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); + } + }} >
@@ -520,7 +550,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..f596e92 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 @@ -96,6 +107,13 @@ export function DropzoneContainer({ onDeleted={refresh} autoUpload initialItems={_initialItems} + footer={ +
$q.setSize($q.size + 1)} + /> + } {...props} > {children @@ -109,6 +127,32 @@ export function DropzoneContainer({ ); } +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/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/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/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="/" /> - +
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