mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge pull request #123 from bknd-io/feat/media-dialog-and-infinite
add media detail dialog and infinite loading
This commit is contained in:
BIN
app/__test__/_assets/image.jpg
Normal file
BIN
app/__test__/_assets/image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 * as utils from "../../src/core/utils";
|
||||||
|
import { assetsPath } from "../helper";
|
||||||
|
|
||||||
async function wait(ms: number) {
|
async function wait(ms: number) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -75,57 +76,6 @@ describe("Core Utils", async () => {
|
|||||||
const result3 = utils.encodeSearch(obj3, { encode: true });
|
const result3 = utils.encodeSearch(obj3, { encode: true });
|
||||||
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
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 () => {
|
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", () => {
|
describe("dates", () => {
|
||||||
test.only("formats local time", () => {
|
test.only("formats local time", () => {
|
||||||
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils";
|
|||||||
|
|
||||||
describe("media/mime-types", () => {
|
describe("media/mime-types", () => {
|
||||||
test("tiny resolves", () => {
|
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) {
|
for (const [ext, mime] of tests) {
|
||||||
expect(tiny.guess(ext)).toBe(mime);
|
expect(tiny.guess(ext)).toBe(mime);
|
||||||
@@ -69,7 +71,7 @@ describe("media/mime-types", () => {
|
|||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
["text/tab-separated-values", "tsv"],
|
["text/tab-separated-values", "tsv"],
|
||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
for (const [mime, ext] of tests) {
|
for (const [mime, ext] of tests) {
|
||||||
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
|
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
|
||||||
@@ -86,7 +88,7 @@ describe("media/mime-types", () => {
|
|||||||
["image.jpeg", "jpeg"],
|
["image.jpeg", "jpeg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
for (const [filename, ext] of tests) {
|
for (const [filename, ext] of tests) {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export class Api {
|
|||||||
this.buildApis();
|
this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get fetcher() {
|
||||||
|
return this.options.fetcher ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return this.options.host ?? "http://localhost";
|
return this.options.host ?? "http://localhost";
|
||||||
}
|
}
|
||||||
|
|||||||
239
app/src/core/utils/file.ts
Normal file
239
app/src/core/utils/file.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
"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<string | undefined> {
|
||||||
|
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<any>): Promise<File> {
|
||||||
|
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<any>): Promise<ReadableStream | File> {
|
||||||
|
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<ImageDim>;
|
||||||
|
export async function detectImageDimensions(input: File): Promise<ImageDim>;
|
||||||
|
export async function detectImageDimensions(
|
||||||
|
input: File | ArrayBuffer,
|
||||||
|
_type?: `image/${string}`,
|
||||||
|
): Promise<ImageDim> {
|
||||||
|
// 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<File> {
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export * from "./browser";
|
|||||||
export * from "./objects";
|
export * from "./objects";
|
||||||
export * from "./strings";
|
export * from "./strings";
|
||||||
export * from "./perf";
|
export * from "./perf";
|
||||||
|
export * from "./file";
|
||||||
export * from "./reqres";
|
export * from "./reqres";
|
||||||
export * from "./xml";
|
export * from "./xml";
|
||||||
export type { Prettify, PrettifyRec } from "./types";
|
export type { Prettify, PrettifyRec } from "./types";
|
||||||
|
|||||||
@@ -11,3 +11,14 @@ export function ensureInt(value?: string | number | null | undefined): number {
|
|||||||
|
|
||||||
return typeof value === "number" ? value : Number.parseInt(value, 10);
|
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];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -97,186 +97,6 @@ export function decodeSearch(str) {
|
|||||||
return out;
|
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<string, string> = {
|
|
||||||
"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<string | undefined> {
|
|
||||||
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<File> {
|
|
||||||
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<any>): Promise<File> {
|
|
||||||
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<any>): Promise<ReadableStream | File> {
|
|
||||||
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
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
||||||
// biome-ignore lint/suspicious/noConstEnum: <explanation>
|
// biome-ignore lint/suspicious/noConstEnum: <explanation>
|
||||||
export const enum HttpStatus {
|
export const enum HttpStatus {
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ export function isNode() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invariant(condition: boolean | any, message: string) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,13 +75,20 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
return this._storage!;
|
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 {
|
return {
|
||||||
path: info.name,
|
path: info.name,
|
||||||
mime_type: info.meta.type,
|
mime_type: info.meta.type,
|
||||||
size: info.meta.size,
|
size: info.meta.size,
|
||||||
etag: info.etag,
|
etag: info.etag,
|
||||||
modified_at: new Date(),
|
modified_at: new Date(),
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { type EmitsEvents, EventManager } from "core/events";
|
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 { isMimeType } from "media/storage/mime-types-tiny";
|
||||||
import * as StorageEvents from "./events";
|
import * as StorageEvents from "./events";
|
||||||
import type { FileUploadedEventData } from "./events";
|
import type { FileUploadedEventData } from "./events";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
export type FileListObject = {
|
export type FileListObject = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -10,7 +11,7 @@ export type FileListObject = {
|
|||||||
size: number;
|
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 FileBody = ReadableStream | File;
|
||||||
export type FileUploadPayload = {
|
export type FileUploadPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -102,7 +103,7 @@ export class Storage implements EmitsEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// try to get better meta info
|
// 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);
|
const meta = await this.#adapter.getObjectMeta(name);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
throw new Error("Failed to get object meta");
|
throw new Error("Failed to get object meta");
|
||||||
@@ -110,6 +111,19 @@ export class Storage implements EmitsEvents {
|
|||||||
info.meta = meta;
|
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 = {
|
const eventData = {
|
||||||
file,
|
file,
|
||||||
...info,
|
...info,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
PutObjectRequest,
|
PutObjectRequest,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { AwsClient, isDebug } from "core";
|
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 { transform } from "lodash-es";
|
||||||
import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
|
import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
|
||||||
|
|
||||||
@@ -178,7 +178,6 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
|||||||
const res = await this.fetch(url, {
|
const res = await this.fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: pickHeaders2(headers, [
|
headers: pickHeaders2(headers, [
|
||||||
"range",
|
|
||||||
"if-none-match",
|
"if-none-match",
|
||||||
"accept-encoding",
|
"accept-encoding",
|
||||||
"accept",
|
"accept",
|
||||||
|
|||||||
@@ -331,4 +331,15 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
|
|||||||
Boolean,
|
Boolean,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.key({ search: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
url: this.request.url,
|
||||||
|
method: this.request.method,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Api } from "Api";
|
import type { 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 useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||||
|
import useSWRInfinite from "swr/infinite";
|
||||||
import { useApi } from "ui/client";
|
import { useApi } from "ui/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export const useApiQuery = <
|
export const useApiQuery = <
|
||||||
Data,
|
Data,
|
||||||
@@ -27,6 +29,50 @@ export const useApiQuery = <
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @attention: highly experimental, use with caution! */
|
||||||
|
export const useApiInfiniteQuery = <
|
||||||
|
Data,
|
||||||
|
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data,
|
||||||
|
>(
|
||||||
|
fn: (api: Api, page: number) => FetchPromise<Data>,
|
||||||
|
options?: SWRConfiguration & { refine?: RefineFn },
|
||||||
|
) => {
|
||||||
|
const [endReached, setEndReached] = useState(false);
|
||||||
|
const api = useApi();
|
||||||
|
const promise = (page: number) => fn(api, page);
|
||||||
|
const refine = options?.refine ?? ((data: any) => data);
|
||||||
|
|
||||||
|
type RefinedData = RefineFn extends (data: ResponseObject<Data>) => infer R ? R : Data;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const swr = useSWRInfinite<RefinedData>(
|
||||||
|
(index, previousPageData: any) => {
|
||||||
|
if (previousPageData && !previousPageData.length) {
|
||||||
|
setEndReached(true);
|
||||||
|
return null; // reached the end
|
||||||
|
}
|
||||||
|
return promise(index).request.url;
|
||||||
|
},
|
||||||
|
(url: string) => {
|
||||||
|
return new FetchPromise(new Request(url), { fetcher: api.fetcher }, refine).execute();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revalidateFirstPage: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// @ts-ignore
|
||||||
|
const data = swr.data ? [].concat(...swr.data) : [];
|
||||||
|
return {
|
||||||
|
...swr,
|
||||||
|
_data: swr.data,
|
||||||
|
data,
|
||||||
|
endReached,
|
||||||
|
promise: promise(swr.size),
|
||||||
|
key: promise(swr.size).key(),
|
||||||
|
api,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const useInvalidate = (options?: { exact?: boolean }) => {
|
export const useInvalidate = (options?: { exact?: boolean }) => {
|
||||||
const mutate = useSWRConfig().mutate;
|
const mutate = useSWRConfig().mutate;
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const JsonViewer = ({
|
|||||||
expand = 0,
|
expand = 0,
|
||||||
showSize = false,
|
showSize = false,
|
||||||
showCopy = false,
|
showCopy = false,
|
||||||
|
copyIconProps = {},
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
json: object;
|
json: object;
|
||||||
@@ -16,6 +17,7 @@ export const JsonViewer = ({
|
|||||||
expand?: number;
|
expand?: number;
|
||||||
showSize?: boolean;
|
showSize?: boolean;
|
||||||
showCopy?: boolean;
|
showCopy?: boolean;
|
||||||
|
copyIconProps?: any;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const size = showSize ? JSON.stringify(json).length : undefined;
|
const size = showSize ? JSON.stringify(json).length : undefined;
|
||||||
@@ -28,7 +30,7 @@ export const JsonViewer = ({
|
|||||||
return (
|
return (
|
||||||
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
||||||
{showContext && (
|
{showContext && (
|
||||||
<div className="absolute right-4 top-4 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
<div className="absolute right-4 top-3 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
||||||
{(title || size) && (
|
{(title || size) && (
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
|
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
|
||||||
@@ -36,7 +38,7 @@ export const JsonViewer = ({
|
|||||||
)}
|
)}
|
||||||
{showCopy && (
|
{showCopy && (
|
||||||
<div>
|
<div>
|
||||||
<IconButton Icon={TbCopy} onClick={onCopy} />
|
<IconButton Icon={TbCopy} onClick={onCopy} {...copyIconProps} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export type DropdownProps = {
|
|||||||
onClickItem?: (item: DropdownItem) => void;
|
onClickItem?: (item: DropdownItem) => void;
|
||||||
renderItem?: (
|
renderItem?: (
|
||||||
item: DropdownItem,
|
item: DropdownItem,
|
||||||
props: { key: number; onClick: () => void },
|
props: { key: number; onClick: (e: any) => void },
|
||||||
) => DropdownClickableChild;
|
) => DropdownClickableChild;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +65,13 @@ export function Dropdown({
|
|||||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0),
|
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) => {
|
const onContextMenuHandler = useEvent((e) => {
|
||||||
if (openEvent !== "onContextMenu") return;
|
if (openEvent !== "onContextMenu") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -165,10 +171,18 @@ export function Dropdown({
|
|||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50">{title}</div>
|
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50 truncate">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{menuItems.map((item, i) =>
|
{menuItems.map((item, i) =>
|
||||||
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }),
|
itemRenderer(item, {
|
||||||
|
key: i,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
internalOnClickItem(item);
|
||||||
|
},
|
||||||
|
}),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function Link({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wouterOnClick = (e: any) => {
|
const wouterOnClick = (e: any) => {
|
||||||
|
onClick?.(e);
|
||||||
// prepared for view transition
|
// prepared for view transition
|
||||||
/*if (props.transition !== false) {
|
/*if (props.transition !== false) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { TbDots } from "react-icons/tb";
|
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
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 { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||||
|
import { formatNumber } from "core/utils";
|
||||||
|
|
||||||
export type FileState = {
|
export type FileState = {
|
||||||
body: FileWithPath | string;
|
body: FileWithPath | string;
|
||||||
@@ -41,6 +42,8 @@ export type DropzoneRenderProps = {
|
|||||||
deleteFile: (file: FileState) => Promise<void>;
|
deleteFile: (file: FileState) => Promise<void>;
|
||||||
openFileInput: () => void;
|
openFileInput: () => void;
|
||||||
};
|
};
|
||||||
|
onClick?: (file: FileState) => void;
|
||||||
|
footer?: ReactNode;
|
||||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,10 +59,12 @@ export type DropzoneProps = {
|
|||||||
onRejected?: (files: FileWithPath[]) => void;
|
onRejected?: (files: FileWithPath[]) => void;
|
||||||
onDeleted?: (file: FileState) => void;
|
onDeleted?: (file: FileState) => void;
|
||||||
onUploaded?: (files: FileStateWithData[]) => void;
|
onUploaded?: (files: FileStateWithData[]) => void;
|
||||||
|
onClick?: (file: FileState) => void;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
footer?: ReactNode;
|
||||||
children?: (props: DropzoneRenderProps) => ReactNode;
|
children?: (props: DropzoneRenderProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,6 +91,8 @@ export function Dropzone({
|
|||||||
onDeleted,
|
onDeleted,
|
||||||
onUploaded,
|
onUploaded,
|
||||||
children,
|
children,
|
||||||
|
onClick,
|
||||||
|
footer,
|
||||||
}: DropzoneProps) {
|
}: DropzoneProps) {
|
||||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
const [files, setFiles] = useState<FileState[]>(initialItems);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
@@ -393,6 +400,8 @@ export function Dropzone({
|
|||||||
autoUpload,
|
autoUpload,
|
||||||
flow,
|
flow,
|
||||||
},
|
},
|
||||||
|
onClick,
|
||||||
|
footer,
|
||||||
};
|
};
|
||||||
|
|
||||||
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
||||||
@@ -404,6 +413,8 @@ const DropzoneInner = ({
|
|||||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||||
actions: { uploadFile, deleteFile, openFileInput },
|
actions: { uploadFile, deleteFile, openFileInput },
|
||||||
dropzoneProps: { placeholder, flow },
|
dropzoneProps: { placeholder, flow },
|
||||||
|
onClick,
|
||||||
|
footer,
|
||||||
}: DropzoneRenderProps) => {
|
}: DropzoneRenderProps) => {
|
||||||
const Placeholder = showPlaceholder && (
|
const Placeholder = showPlaceholder && (
|
||||||
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
|
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
|
||||||
@@ -438,9 +449,11 @@ const DropzoneInner = ({
|
|||||||
file={file}
|
file={file}
|
||||||
handleUpload={uploadHandler}
|
handleUpload={uploadHandler}
|
||||||
handleDelete={deleteFile}
|
handleDelete={deleteFile}
|
||||||
|
onClick={onClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{flow === "end" && Placeholder}
|
{flow === "end" && Placeholder}
|
||||||
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,7 +463,7 @@ const DropzoneInner = ({
|
|||||||
const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-[49%] aspect-[1/0.9] md:w-60 flex flex-col border-2 border-dashed border-muted relative justify-center items-center text-primary/30 hover:border-primary/30 hover:text-primary/50 hover:cursor-pointer hover:bg-muted/20 transition-colors duration-200"
|
className="w-[49%] aspect-square md:w-60 flex flex-col border-2 border-dashed border-muted relative justify-center items-center text-primary/30 hover:border-primary/30 hover:text-primary/50 hover:cursor-pointer hover:bg-muted/20 transition-colors duration-200"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<span className="">{text}</span>
|
<span className="">{text}</span>
|
||||||
@@ -486,26 +499,43 @@ type PreviewProps = {
|
|||||||
file: FileState;
|
file: FileState;
|
||||||
handleUpload: (file: FileState) => Promise<void>;
|
handleUpload: (file: FileState) => Promise<void>;
|
||||||
handleDelete: (file: FileState) => Promise<void>;
|
handleDelete: (file: FileState) => Promise<void>;
|
||||||
|
onClick?: (file: FileState) => void;
|
||||||
};
|
};
|
||||||
const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
|
const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => {
|
||||||
const dropdownItems = [
|
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) && {
|
["initial", "uploaded"].includes(file.state) && {
|
||||||
label: "Delete",
|
label: "Delete",
|
||||||
|
destructive: true,
|
||||||
|
icon: TbTrash,
|
||||||
onClick: () => handleDelete(file),
|
onClick: () => handleDelete(file),
|
||||||
},
|
},
|
||||||
["initial", "pending"].includes(file.state) && {
|
["initial", "pending"].includes(file.state) && {
|
||||||
label: "Upload",
|
label: "Upload",
|
||||||
|
icon: TbUpload,
|
||||||
onClick: () => handleUpload(file),
|
onClick: () => handleUpload(file),
|
||||||
},
|
},
|
||||||
];
|
] satisfies (DropdownItem | boolean)[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"w-[49%] md:w-60 flex flex-col border border-muted relative",
|
"w-[49%] md:w-60 aspect-square flex flex-col border border-muted relative hover:bg-primary/5 cursor-pointer transition-colors",
|
||||||
file.state === "failed" && "border-red-500 bg-red-200/20",
|
file.state === "failed" && "border-red-500 bg-red-200/20",
|
||||||
file.state === "deleting" && "opacity-70",
|
file.state === "deleting" && "opacity-70",
|
||||||
)}
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<Dropdown items={dropdownItems} position="bottom-end">
|
<Dropdown items={dropdownItems} position="bottom-end">
|
||||||
@@ -520,7 +550,7 @@ const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
|
<div className="flex bg-primary/5 aspect-[1/0.78] overflow-hidden items-center justify-center">
|
||||||
<PreviewWrapperMemoized
|
<PreviewWrapperMemoized
|
||||||
file={file}
|
file={file}
|
||||||
fallback={FallbackPreview}
|
fallback={FallbackPreview}
|
||||||
@@ -531,7 +561,7 @@ const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
|
|||||||
<p className="truncate select-text">{file.name}</p>
|
<p className="truncate select-text">{file.name}</p>
|
||||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||||
<span className="truncate select-text">{file.type}</span>
|
<span className="truncate select-text">{file.type}</span>
|
||||||
<span>{(file.size / 1024).toFixed(1)} KB</span>
|
<span>{formatNumber.fileSize(file.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import type { Api } from "bknd/client";
|
|||||||
import type { RepoQueryIn } from "data";
|
import type { RepoQueryIn } from "data";
|
||||||
import type { MediaFieldSchema } from "media/AppMedia";
|
import type { MediaFieldSchema } from "media/AppMedia";
|
||||||
import type { TAppMediaConfig } from "media/media-schema";
|
import type { TAppMediaConfig } from "media/media-schema";
|
||||||
import { type ReactNode, createContext, useContext, useId } from "react";
|
import {
|
||||||
import { useApi, useApiQuery, useInvalidate } from "ui/client";
|
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 { useEvent } from "ui/hooks/use-event";
|
||||||
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
|
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
|
||||||
import { mediaItemsToFileStates } from "./helper";
|
import { mediaItemsToFileStates } from "./helper";
|
||||||
|
import { useInViewport } from "@mantine/hooks";
|
||||||
|
|
||||||
export type DropzoneContainerProps = {
|
export type DropzoneContainerProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -36,30 +45,32 @@ export function DropzoneContainer({
|
|||||||
const api = useApi();
|
const api = useApi();
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
const baseUrl = api.baseUrl;
|
const baseUrl = api.baseUrl;
|
||||||
const defaultQuery = {
|
const pageSize = query?.limit ?? props.maxItems ?? 50;
|
||||||
limit: query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50,
|
const defaultQuery = (page: number) => ({
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize,
|
||||||
sort: "-id",
|
sort: "-id",
|
||||||
};
|
});
|
||||||
const entity_name = (media?.entity_name ?? "media") as "media";
|
const entity_name = (media?.entity_name ?? "media") as "media";
|
||||||
//console.log("dropzone:baseUrl", baseUrl);
|
//console.log("dropzone:baseUrl", baseUrl);
|
||||||
|
|
||||||
const selectApi = (api: Api) =>
|
const selectApi = (api: Api, page: number) =>
|
||||||
entity
|
entity
|
||||||
? api.data.readManyByReference(entity.name, entity.id, entity.field, {
|
? api.data.readManyByReference(entity.name, entity.id, entity.field, {
|
||||||
...defaultQuery,
|
|
||||||
...query,
|
...query,
|
||||||
where: {
|
where: {
|
||||||
reference: `${entity.name}.${entity.field}`,
|
reference: `${entity.name}.${entity.field}`,
|
||||||
entity_id: entity.id,
|
entity_id: entity.id,
|
||||||
...query?.where,
|
...query?.where,
|
||||||
},
|
},
|
||||||
|
...defaultQuery(page),
|
||||||
})
|
})
|
||||||
: api.data.readMany(entity_name, {
|
: api.data.readMany(entity_name, {
|
||||||
...defaultQuery,
|
|
||||||
...query,
|
...query,
|
||||||
|
...defaultQuery(page),
|
||||||
});
|
});
|
||||||
|
|
||||||
const $q = useApiQuery(selectApi, { enabled: initialItems !== false && !initialItems });
|
const $q = useApiInfiniteQuery(selectApi, {});
|
||||||
|
|
||||||
const getUploadInfo = useEvent((file) => {
|
const getUploadInfo = useEvent((file) => {
|
||||||
const url = entity
|
const url = entity
|
||||||
@@ -96,6 +107,13 @@ export function DropzoneContainer({
|
|||||||
onDeleted={refresh}
|
onDeleted={refresh}
|
||||||
autoUpload
|
autoUpload
|
||||||
initialItems={_initialItems}
|
initialItems={_initialItems}
|
||||||
|
footer={
|
||||||
|
<Footer
|
||||||
|
items={_initialItems.length}
|
||||||
|
length={$q._data?.[0]?.body.meta.count ?? 0}
|
||||||
|
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children
|
{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) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={i === 0 ? ref : undefined}
|
||||||
|
className="w-[49%] md:w-60 bg-muted aspect-square"
|
||||||
|
>
|
||||||
|
{i === 0 ? (inViewport ? `load ${visible}` : "first") : "other"}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
export function useDropzone() {
|
export function useDropzone() {
|
||||||
return useContext(DropzoneContainerContext);
|
return useContext(DropzoneContainerContext);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { isDebug } from "core";
|
|||||||
|
|
||||||
export const useEvent = <Fn>(fn: Fn): Fn => {
|
export const useEvent = <Fn>(fn: Fn): Fn => {
|
||||||
if (isDebug()) {
|
if (isDebug()) {
|
||||||
console.warn("useEvent() is deprecated");
|
//console.warn("useEvent() is deprecated");
|
||||||
}
|
}
|
||||||
return fn;
|
return fn;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function Main({ children }) {
|
|||||||
export function Sidebar({ children }) {
|
export function Sidebar({ children }) {
|
||||||
const open = appShellStore((store) => store.sidebarOpen);
|
const open = appShellStore((store) => store.sidebarOpen);
|
||||||
const close = appShellStore((store) => store.closeSidebar);
|
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 [location] = useLocation();
|
||||||
|
|
||||||
const closeHandler = () => {
|
const closeHandler = () => {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function HeaderNavigation() {
|
|||||||
function SidebarToggler() {
|
function SidebarToggler() {
|
||||||
const toggle = appShellStore((store) => store.toggleSidebar);
|
const toggle = appShellStore((store) => store.toggleSidebar);
|
||||||
const open = appShellStore((store) => store.sidebarOpen);
|
const open = appShellStore((store) => store.sidebarOpen);
|
||||||
return <IconButton size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
|
return <IconButton id="toggle-sidebar" size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ hasSidebar = true }) {
|
export function Header({ hasSidebar = true }) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function createMantineTheme(scheme: "light" | "dark"): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const input =
|
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 {
|
return {
|
||||||
theme: createTheme({
|
theme: createTheme({
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { CreateModal } from "ui/modules/data/components/schema/create-modal/Crea
|
|||||||
import { DebugModal } from "./debug/DebugModal";
|
import { DebugModal } from "./debug/DebugModal";
|
||||||
import { SchemaFormModal } from "./debug/SchemaFormModal";
|
import { SchemaFormModal } from "./debug/SchemaFormModal";
|
||||||
import { TestModal } from "./debug/TestModal";
|
import { TestModal } from "./debug/TestModal";
|
||||||
|
import { scaleFadeIn } from "ui/modals/transitions";
|
||||||
|
import { MediaInfoModal } from "ui/modals/media/MediaInfoModal";
|
||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
test: TestModal,
|
test: TestModal,
|
||||||
@@ -13,6 +15,7 @@ const modals = {
|
|||||||
form: SchemaFormModal,
|
form: SchemaFormModal,
|
||||||
overlay: OverlayModal,
|
overlay: OverlayModal,
|
||||||
dataCreate: CreateModal,
|
dataCreate: CreateModal,
|
||||||
|
mediaInfo: MediaInfoModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module "@mantine/modals" {
|
declare module "@mantine/modals" {
|
||||||
@@ -38,8 +41,14 @@ function open<Modal extends keyof typeof modals>(
|
|||||||
...cmpModalProps,
|
...cmpModalProps,
|
||||||
modal,
|
modal,
|
||||||
innerProps,
|
innerProps,
|
||||||
};
|
} as any;
|
||||||
openContextModal(props);
|
openContextModal({
|
||||||
|
transitionProps: {
|
||||||
|
transition: scaleFadeIn,
|
||||||
|
duration: 300,
|
||||||
|
},
|
||||||
|
...props,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
close: () => close(modal),
|
close: () => close(modal),
|
||||||
closeAll: $modals.closeAll,
|
closeAll: $modals.closeAll,
|
||||||
|
|||||||
177
app/src/ui/modals/media/MediaInfoModal.tsx
Normal file
177
app/src/ui/modals/media/MediaInfoModal.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import type { ContextModalProps } from "@mantine/modals";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEntityQuery } from "ui/client";
|
||||||
|
import { type FileState, Media } from "ui/elements";
|
||||||
|
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
|
import { TbCheck, TbCopy } from "react-icons/tb";
|
||||||
|
import { useClipboard } from "@mantine/hooks";
|
||||||
|
import { ButtonLink } from "ui/components/buttons/Button";
|
||||||
|
import { routes } from "ui/lib/routes";
|
||||||
|
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
|
||||||
|
import { JsonViewer } from "ui";
|
||||||
|
|
||||||
|
export type MediaInfoModalProps = {
|
||||||
|
file: FileState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaInfoModal({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
innerProps: { file },
|
||||||
|
}: ContextModalProps<MediaInfoModalProps>) {
|
||||||
|
const {
|
||||||
|
config: { entity_name, basepath },
|
||||||
|
} = useBkndMedia();
|
||||||
|
const $q = useEntityQuery(entity_name as "media", undefined, {
|
||||||
|
where: {
|
||||||
|
path: file.path,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const close = () => context.closeModal(id);
|
||||||
|
const data = $q.data?.[0];
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const entity = data?.reference ? data?.reference.split(".")[0] : undefined;
|
||||||
|
const entityUrl = entity
|
||||||
|
? "/data" + routes.data.entity.edit(entity, data?.entity_id!)
|
||||||
|
: undefined;
|
||||||
|
const mediaUrl = data?.path
|
||||||
|
? "/data" + routes.data.entity.edit(entity_name, data?.id!)
|
||||||
|
: undefined;
|
||||||
|
//const assetUrl = data?.path ? origin + basepath + "/file/" + data?.path : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Media.Preview file={file} className="max-h-[70dvh]" controls muted />
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:!w-[300px] flex flex-col">
|
||||||
|
<Item title="ID" value={data?.id} copyValue={origin + mediaUrl} first>
|
||||||
|
{mediaUrl && (
|
||||||
|
<ButtonLink
|
||||||
|
href={mediaUrl!}
|
||||||
|
size="small"
|
||||||
|
className="py-1.5 px-2 !leading-none font-mono"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
#{String(data?.id)}
|
||||||
|
</ButtonLink>
|
||||||
|
)}
|
||||||
|
</Item>
|
||||||
|
<Item title="Path" value={data?.path} />
|
||||||
|
<Item title="Mime Type" value={data?.mime_type} />
|
||||||
|
<Item
|
||||||
|
title="Size"
|
||||||
|
value={data?.size && formatNumber.fileSize(data.size, 1)}
|
||||||
|
copyValue={data?.size}
|
||||||
|
/>
|
||||||
|
<Item title="Etag" value={data?.etag} />
|
||||||
|
<Item title="Entity" copyValue={origin + entityUrl}>
|
||||||
|
{entityUrl && (
|
||||||
|
<ButtonLink
|
||||||
|
href={entityUrl!}
|
||||||
|
size="small"
|
||||||
|
className="py-1.5 px-2 !leading-none font-mono"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
{data?.reference} #{data?.entity_id}
|
||||||
|
</ButtonLink>
|
||||||
|
)}
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title="Modified At"
|
||||||
|
value={data?.modified_at && datetimeStringLocal(data?.modified_at)}
|
||||||
|
copyValue={data?.modified_at}
|
||||||
|
/>
|
||||||
|
<Item title="Metadata" value={data?.metadata} copyable={false}>
|
||||||
|
{data?.metadata && (
|
||||||
|
<JsonViewer
|
||||||
|
json={data?.metadata}
|
||||||
|
expand={2}
|
||||||
|
showCopy
|
||||||
|
className="w-full text-sm bg-primary/2 pt-2.5 rounded-lg"
|
||||||
|
copyIconProps={{
|
||||||
|
className: "size-6 opacity-20 group-hover:opacity-100 transition-all",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
first,
|
||||||
|
copyable = true,
|
||||||
|
copyValue,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
value?: any;
|
||||||
|
first?: boolean;
|
||||||
|
copyable?: boolean;
|
||||||
|
copyValue?: any;
|
||||||
|
}) => {
|
||||||
|
const cb = useClipboard();
|
||||||
|
|
||||||
|
const is_null = !children && (value === null || typeof value === "undefined");
|
||||||
|
const can_copy = copyable && !is_null && cb.copy !== undefined;
|
||||||
|
const _value = value
|
||||||
|
? typeof value === "object" && !is_null
|
||||||
|
? JSON.stringify(value)
|
||||||
|
: String(value)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-col gap-1 py-3 pl-5 pr-3 group",
|
||||||
|
!first && "border-t border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-bold opacity-50">{autoFormatString(title)}</div>
|
||||||
|
<div className="flex flex-row gap-1 justify-between items-center">
|
||||||
|
{children ?? (
|
||||||
|
<div className={twMerge("font-mono truncate", is_null && "opacity-30")}>
|
||||||
|
{is_null ? "null" : _value}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{can_copy && (
|
||||||
|
<IconButton
|
||||||
|
Icon={cb.copied ? TbCheck : TbCopy}
|
||||||
|
className={twMerge(
|
||||||
|
"size-6 opacity-20 group-hover:opacity-100 transition-all",
|
||||||
|
cb.copied && "text-success-foreground opacity-100",
|
||||||
|
)}
|
||||||
|
onClick={() => cb.copy(copyValue ? copyValue : value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaInfoModal.defaultTitle = undefined;
|
||||||
|
MediaInfoModal.modalProps = {
|
||||||
|
withCloseButton: false,
|
||||||
|
size: "auto",
|
||||||
|
//size: "90%",
|
||||||
|
centered: true,
|
||||||
|
styles: {
|
||||||
|
content: {
|
||||||
|
overflowY: "initial !important",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
classNames: {
|
||||||
|
root: "bknd-admin w-full max-w-xl",
|
||||||
|
content: "overflow-hidden",
|
||||||
|
title: "font-bold !text-md",
|
||||||
|
body: "max-h-inherit !p-0",
|
||||||
|
},
|
||||||
|
};
|
||||||
7
app/src/ui/modals/transitions.ts
Normal file
7
app/src/ui/modals/transitions.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { MantineTransition } from "@mantine/core";
|
||||||
|
|
||||||
|
export const scaleFadeIn: MantineTransition = {
|
||||||
|
in: { opacity: 1, transform: "scale(1)" },
|
||||||
|
out: { opacity: 0, transform: "scale(0.9)" },
|
||||||
|
transitionProperty: "transform, opacity",
|
||||||
|
};
|
||||||
@@ -15,12 +15,13 @@ import { type ComponentProps, Suspense } from "react";
|
|||||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
import { FieldLabel } 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 { useEvent } from "ui/hooks/use-event";
|
||||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||||
import { Alert } from "ui/components/display/Alert";
|
import { Alert } from "ui/components/display/Alert";
|
||||||
|
import { bkndModals } from "ui/modals";
|
||||||
|
|
||||||
// simplify react form types 🤦
|
// simplify react form types 🤦
|
||||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
||||||
@@ -237,6 +238,11 @@ function EntityMediaFormField({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const key = JSON.stringify([entity, entityId, field.name, value.length]);
|
const key = JSON.stringify([entity, entityId, field.name, value.length]);
|
||||||
|
const onClick = (file: FileState) => {
|
||||||
|
bkndModals.open(bkndModals.ids.mediaInfo, {
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group>
|
<Formy.Group>
|
||||||
@@ -245,6 +251,7 @@ function EntityMediaFormField({
|
|||||||
key={key}
|
key={key}
|
||||||
maxItems={field.getMaxItems()}
|
maxItems={field.getMaxItems()}
|
||||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||||
|
onClick={onClick}
|
||||||
entity={{
|
entity={{
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
id: entityId,
|
id: entityId,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function DataSchemaEntity({ params }) {
|
|||||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||||
backTo="/"
|
backTo="/"
|
||||||
/>
|
/>
|
||||||
<Link to="/" className="invisible md:visible">
|
<Link to="/" className="hidden md:inline">
|
||||||
<Button IconLeft={TbSitemap}>Overview</Button>
|
<Button IconLeft={TbSitemap}>Overview</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { IconPhoto } from "@tabler/icons-react";
|
import { IconPhoto } from "@tabler/icons-react";
|
||||||
import { useBknd } from "ui/client/BkndProvider";
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
import { Empty } from "ui/components/display/Empty";
|
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 { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
import { bkndModals } from "ui/modals";
|
||||||
|
|
||||||
export function MediaIndex() {
|
export function MediaIndex() {
|
||||||
const { app, config } = useBknd();
|
const { config } = useBknd();
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
useBrowserTitle(["Media"]);
|
useBrowserTitle(["Media"]);
|
||||||
|
|
||||||
@@ -25,10 +26,16 @@ export function MediaIndex() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClick = (file: FileState) => {
|
||||||
|
bkndModals.open(bkndModals.ids.mediaInfo, {
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div className="flex flex-1 p-3">
|
<div className="flex flex-1 p-3">
|
||||||
<Media.Dropzone />
|
<Media.Dropzone onClick={onClick} />
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function DropzoneElementTest() {
|
|||||||
</Media.Dropzone>
|
</Media.Dropzone>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/*<div>
|
||||||
<b>Dropzone User Avatar 1 (overwrite)</b>
|
<b>Dropzone User Avatar 1 (overwrite)</b>
|
||||||
<Media.Dropzone
|
<Media.Dropzone
|
||||||
entity={{ name: "users", id: 1, field: "avatar" }}
|
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||||
@@ -36,7 +36,7 @@ export default function DropzoneElementTest() {
|
|||||||
<div>
|
<div>
|
||||||
<b>Dropzone Container blank w/ query</b>
|
<b>Dropzone Container blank w/ query</b>
|
||||||
<Media.Dropzone query={{ limit: 2 }} />
|
<Media.Dropzone query={{ limit: 2 }} />
|
||||||
</div>
|
</div>*/}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<b>Dropzone Container blank</b>
|
<b>Dropzone Container blank</b>
|
||||||
|
|||||||
Reference in New Issue
Block a user