This commit is contained in:
Rizky 2024-02-24 16:19:53 +07:00
parent bcfb1ced0c
commit 4d60d67f04
13 changed files with 582 additions and 158 deletions

File diff suppressed because one or more lines are too long

View File

@ -13,6 +13,7 @@
"@wojtekmaj/react-qr-svg": "^1.0.0",
"constrained-editor-plugin": "^1.3.0",
"react-resizable-panels": "^2.0.9",
"axios": "^1.6.7",
"@monaco-editor/react": "^4.6.0",
"@paralleldrive/cuid2": "2.2.2",
"react-contenteditable": "^3.3.7",

View File

@ -28,14 +28,17 @@ export const apiProxy = (api_url: string) => {
{
get: (_, actionName: string) => {
if (actionName === "_url") {
return (pathname: string) => {
return (pathname: string, proxy?: boolean) => {
const to_url = new URL(base_url);
to_url.pathname = pathname;
to_url.pathname = pathname
.split("/")
.filter((e) => e)
.join("/");
const cur_url = new URL(location.href);
let final_url = "";
if (to_url.host === cur_url.host) {
if (to_url.host === cur_url.host || proxy === false) {
final_url = to_url.toString();
} else {
final_url = `${cur_url.protocol}//${
@ -155,3 +158,4 @@ const fetchSendApi = async (url: string, params: any) => {
"content-type": "application/json",
});
};

View File

@ -1,3 +1,5 @@
import axios from "axios";
(BigInt.prototype as any).toJSON = function (): string {
return `BigInt::` + this.toString();
};
@ -12,6 +14,7 @@ export const fetchViaProxy = async (
let body = null as any;
let isFile = false;
let uploadProgress = null as any;
const files: File[] = [];
if (Array.isArray(data)) {
@ -20,11 +23,15 @@ export const fetchViaProxy = async (
files.push(item);
isFile = true;
}
if (typeof item === "function") {
uploadProgress = item;
}
}
} else if (data instanceof File) {
isFile = true;
files.push(data);
}
if (!isFile) {
body = JSON.stringify(data);
headers["content-type"] = "aplication/json";
@ -53,21 +60,32 @@ export const fetchViaProxy = async (
}
if (final_url) {
const res = await fetch(
final_url,
data
? {
method: "POST",
body,
headers,
}
: undefined
);
const raw = await res.text();
try {
return JSON.parse(raw);
} catch (e) {
return raw;
if (uploadProgress) {
const res = await axios({
method: data ? "post" : undefined,
url: final_url,
data: body,
onUploadProgress: uploadProgress,
});
return res.data;
} else {
const res = await fetch(
final_url,
data
? {
method: "POST",
body,
headers,
}
: undefined
);
const raw = await res.text();
try {
return JSON.parse(raw);
} catch (e) {
return raw;
}
}
}
}

View File

@ -255,6 +255,12 @@ export const EDGlobal = {
HTMLElement,
MouseEvent
>,
preview: true,
upload: {
started: false,
progress: {} as Record<string, number>,
},
},
code: {
init: false,

View File

@ -10,10 +10,11 @@ import { EdFileTop } from "./file-top";
import { EdFileTree, reloadFileTree } from "./file-tree";
import { uploadFile } from "./file-upload";
import { FEntry } from "./type";
import { EdFilePreview } from "./file-preview";
export const EdFileBrowser = () => {
const p = useGlobal(EDGlobal, "EDITOR");
const f = p.ui.popup.file;
useEffect(() => {
if (!p.script.api && p.site.config?.api_url) {
p.script.api = apiProxy(p.site.config.api_url);
@ -24,12 +25,12 @@ export const EdFileBrowser = () => {
p.script.api._raw(`/_file/?dir`).then((e: FEntry[]) => {
if (Array.isArray(e)) {
p.ui.popup.file.entry = { "/": e };
f.entry = { "/": e };
if (p.ui.popup.file.open) {
if (f.open) {
reloadFileTree(p);
}
p.ui.popup.file.enabled = true;
f.enabled = true;
p.render();
}
});
@ -40,14 +41,14 @@ export const EdFileBrowser = () => {
noClick: true,
});
if (!p.ui.popup.file.enabled) return null;
if (!f.enabled) return null;
return (
<>
<div
className="items-center flex px-2 cursor-pointer border border-transparent hover:bg-slate-200 transition-all hover:border-black"
onClick={() => {
p.ui.popup.file.open = true;
f.open = true;
p.render();
reloadFileTree(p);
}}
@ -71,10 +72,10 @@ export const EdFileBrowser = () => {
<Modal
fade={false}
open={p.ui.popup.file.open}
open={f.open}
onOpenChange={(open) => {
if (!open) {
p.ui.popup.file.open = false;
f.open = false;
p.render();
}
}}
@ -82,7 +83,6 @@ export const EdFileBrowser = () => {
<div className={cx("bg-white select-none fixed inset-[50px] flex")}>
<PanelGroup direction="horizontal" className="text-sm">
<Panel
id="tree"
defaultSize={parseInt(
localStorage.getItem("panel-file-left") || "18"
)}
@ -102,35 +102,93 @@ export const EdFileBrowser = () => {
<Panel order={2}>
<div className="flex-1 flex h-full flex-col">
<EdFileTop />
<div
className={cx("flex-1 flex h-full outline-none relative")}
{...getRootProps()}
>
<EdFileList />
<input {...getInputProps()} />
{isDragActive && (
<div className="absolute inset-0 flex items-center justify-center flex-col bg-blue-50 border-4 border-blue-500">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="lucide lucide-upload"
viewBox="0 0 24 24"
<PanelGroup direction="horizontal">
<Panel order={1}>
{f.upload.started ? (
<div className="flex flex-col items-center justify-center flex-1 h-full">
<div
className={cx(
"flex flex-col items-stretch min-w-[30%]"
)}
>
<div className="border-b pb-2">
Uploading {Object.keys(f.upload.progress).length}{" "}
files
</div>
{Object.entries(f.upload.progress).map(
([name, progress]) => {
return (
<div
className="flex justify-between border-b p-1"
key={name}
>
<div>{name}</div>
<div>{Math.round(progress * 100)}%</div>
</div>
);
}
)}
</div>
</div>
) : (
<div
className={cx(
"flex-1 flex h-full outline-none relative"
)}
{...getRootProps()}
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"></path>
<path d="M17 8L12 3 7 8"></path>
<path d="M12 3L12 15"></path>
</svg>
<div>Drag Here to Upload</div>
</div>
<EdFileList />
<input {...getInputProps()} />
{isDragActive && (
<div className="absolute inset-0 flex items-center justify-center flex-col bg-blue-50 border-4 border-blue-500">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="lucide lucide-upload"
viewBox="0 0 24 24"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"></path>
<path d="M17 8L12 3 7 8"></path>
<path d="M12 3L12 15"></path>
</svg>
<div>Drag Here to Upload</div>
</div>
)}
</div>
)}
</Panel>
{f.preview && (
<>
<PanelResizeHandle
className={cx(
"border-r",
css`
width: 10px;
`
)}
/>
<Panel
order={2}
onResize={(e) => {
localStorage.setItem("panel-file-right", e + "");
}}
defaultSize={parseInt(
localStorage.getItem("panel-file-right") || "18"
)}
className="flex items-center justify-center"
minSize={12}
>
<EdFilePreview />
</Panel>
</>
)}
</div>
</PanelGroup>
</div>
</Panel>
</PanelGroup>

View File

@ -7,9 +7,10 @@ import {
import { FC, useCallback, useEffect } from "react";
import { DndProvider } from "react-dnd";
import { useGlobal, useLocal } from "web-utils";
import { Menu, MenuItem } from "../../../../utils/ui/context-menu";
import { EDGlobal, PG } from "../../logic/ed-global";
import { FEntry } from "./type";
import { Menu, MenuItem } from "../../../../utils/ui/context-menu";
import { reloadFileTree } from "./file-tree";
const Tree = DNDTree<FEntry>;
@ -33,28 +34,38 @@ export const EdFileList = () => {
container: null as null | HTMLDivElement,
});
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
local.multi = true;
local.render();
}
if (e.altKey) {
local.inverse = true;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
f.selected.clear();
for (const item of tree) {
if (item.data) f.selected.add(item.data.name);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
local.multi = true;
p.render();
}
local.render();
}
}, []);
if (e.altKey) {
local.inverse = true;
local.multi = true;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
if (document.activeElement?.tagName.toLowerCase() !== "input") {
f.selected.clear();
const tree = f.entry[f.path];
if (tree) {
for (const item of tree) {
if (item.name) f.selected.add(item.name);
}
}
p.render();
}
}
},
[f.entry[f.path]]
);
const onKeyUp = useCallback(() => {
local.multi = false;
local.inverse = false;
local.render();
p.render();
}, []);
useEffect(() => {
@ -87,22 +98,51 @@ export const EdFileList = () => {
>
<MenuItem
label={"Rename"}
disabled={f.selected.size === 0}
disabled={f.selected.size !== 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
f.file_ctx_menu_event = null;
p.render();
setTimeout(async () => {
const selected = [...f.selected];
const rename_to = prompt("Rename to:", selected[0]);
await p.script.api._raw(
`/_file${join(f.path, selected[0])}?rename=${rename_to}`
);
reloadFileList(p);
}, 100);
}}
/>
<MenuItem
label={"Delete"}
disabled={f.selected.size === 0}
onClick={async (e) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (confirm("Delete this file ?")) {
await p.script.api._raw(`/_file${f.path}?del`);
}
f.file_ctx_menu_event = null;
p.render();
setTimeout(async () => {
const selected = [...f.selected].map((e) =>
f.path.endsWith("/") ? e : "/" + e
);
if (f.selected.size === 1) {
if (confirm("Delete this file ?")) {
await p.script.api._raw(
`/_file${join(f.path, selected[0])}?del`
);
}
} else {
if (confirm(`Delete ${f.selected.size} files?`)) {
for (const s of selected) {
await p.script.api._raw(`/_file${join(f.path, s)}?del`);
}
}
}
reloadFileTree(p);
}, 100);
}}
/>
</Menu>
@ -148,7 +188,9 @@ export const EdFileList = () => {
}
if (sq.el && sq.box.w > 5 && sq.box.h > 5) {
f.selected.clear();
if (!local.multi) {
f.selected.clear();
}
for (const [name, el] of Object.entries(local.els)) {
if (overlaps(sq.el, el)) {
if (!local.inverse) {
@ -163,17 +205,16 @@ export const EdFileList = () => {
local.square.up = () => {
window.removeEventListener("pointerup", local.square.up);
local.square.up = null;
local.multi = false;
setTimeout(() => {
local.square.started = false;
local.render();
p.render();
});
};
window.addEventListener("pointerup", local.square.up);
}
}
local.render();
p.render();
}
}}
onPointerDown={(e) => {
@ -184,7 +225,7 @@ export const EdFileList = () => {
sq.start.x = e.clientX - box.x;
sq.start.y = el.scrollTop + e.clientY - box.y;
sq.box = { x: 0, y: 0, w: 0, h: 0 };
local.render();
p.render();
}
}}
onPointerUp={() => {
@ -192,7 +233,7 @@ export const EdFileList = () => {
if (!sq.disabled && sq.started) {
sq.started = false;
}
local.render();
p.render();
}}
>
<div
@ -202,14 +243,17 @@ export const EdFileList = () => {
}
}}
className={cx(
"bg-blue-200 border border-blue-500 absolute z-10 bg-opacity-30 transition-opacity pointer-events-none",
"border absolute z-10 bg-opacity-30 transition-opacity pointer-events-none",
css`
left: ${sq.box.x}px;
top: ${sq.box.y}px;
width: ${sq.box.w}px;
height: ${sq.box.h}px;
`,
sq.started ? "opacity-100" : "opacity-0"
sq.started ? "opacity-100" : "opacity-0",
local.inverse
? "bg-orange-200 border-orange-500"
: "bg-blue-200 border-blue-500"
)}
></div>
@ -221,13 +265,18 @@ export const EdFileList = () => {
display: flex;
flex: 1;
flex-wrap: wrap;
li {
margin-left: 5px;
margin-top: 5px;
}
}
`
)}
onPointerDown={() => {
if (!sq.disabled) {
if (!sq.disabled && !local.multi) {
f.selected.clear();
local.render();
p.render();
}
}}
>
@ -259,7 +308,7 @@ export const EdFileList = () => {
sq.start.x = e.clientX - container.x;
sq.start.y = el.scrollTop + e.clientY - container.y;
sq.box = { x: 0, y: 0, w: 0, h: 0 };
local.render();
p.render();
}
}
}}
@ -270,11 +319,11 @@ export const EdFileList = () => {
}}
onDragStart={() => {
sq.started = false;
local.render();
p.render();
}}
onDragEnd={() => {
sq.item_drag = false;
local.render();
p.render();
}}
/>
</DndProvider>
@ -331,7 +380,7 @@ const FileItem: FC<{
onPointerDown={(ev) => {
if (f.selected.has(e.name)) {
local.square.disabled = true;
local.render();
p.render();
return;
}
if (!local.square.item_drag) {
@ -341,9 +390,11 @@ const FileItem: FC<{
}
if (!local.square.started && f.selected.size <= 1) {
local.square.disabled = true;
f.selected.clear();
if (!local.multi) {
f.selected.clear();
}
f.selected.add(e.name);
local.render();
p.render();
}
}}
onPointerUp={(ev) => {
@ -351,7 +402,7 @@ const FileItem: FC<{
if (local.square.disabled) {
ev.stopPropagation();
local.square.disabled = false;
local.render();
p.render();
} else {
setTimeout(() => {
if (
@ -359,9 +410,11 @@ const FileItem: FC<{
local.square.box.h < 10 &&
!f.selected.has(e.name)
) {
f.selected.clear();
if (!local.multi) {
f.selected.clear();
}
f.selected.add(e.name);
local.render();
p.render();
}
});
}
@ -385,7 +438,11 @@ const FileItem: FC<{
{isImage(ext) ? (
<img
draggable={false}
src={p.script.api._url(`/_img/${f.path}/${e.name}?w=100`)}
src={p.script.api._url(
`/_img${f.path.startsWith("/") ? f.path : `/${f.path}`}/${
e.name
}?w=100`
)}
alt={e.name + " thumbnail (100px)"}
onError={() => {
item.no_image = true;
@ -411,7 +468,7 @@ const FileItem: FC<{
);
};
const isImage = (ext: string) => {
export const isImage = (ext: string) => {
if (["gif", "jpeg", "jpg", "png", "svg", "webp"].includes(ext)) return true;
};
function overlaps(a: HTMLDivElement, b: HTMLDivElement) {
@ -424,3 +481,24 @@ function overlaps(a: HTMLDivElement, b: HTMLDivElement) {
const isOverlapping = isInHoriztonalBounds && isInVerticalBounds;
return isOverlapping;
}
export const reloadFileList = async (p: PG) => {
const f = p.ui.popup.file;
const res = await p.script.api._raw(`/_file${f.path}?dir`);
f.entry[f.path] = res;
p.render();
};
const join = (...arg: string[]) => {
let arr: string[] = [];
for (const s of arg) {
s.split("/").forEach((e) => {
arr.push(e);
});
}
arr = arr.filter((e) => e);
return "/" + arg.join("/");
};

View File

@ -0,0 +1,136 @@
import { useGlobal, useLocal } from "web-utils";
import { EDGlobal } from "../../logic/ed-global";
import { isImage } from "./file-list";
import { FEntry } from "./type";
export const EdFilePreview = () => {
const p = useGlobal(EDGlobal, "EDITOR");
const f = p.ui.popup.file;
const local = useLocal({ no_image: false });
const file_by_ext: Record<string, string[]> = {};
let ext = "";
let first = undefined as FEntry | undefined;
for (const file of f.selected) {
const f_ext = file.split(".").pop() || "";
if (f_ext) {
if (!ext) {
ext = f_ext;
first = f.entry[f.path]?.find((e) => e.name === file);
}
if (!file_by_ext[f_ext]) file_by_ext[f_ext] = [];
file_by_ext[f_ext].push(file);
}
}
return (
<>
{f.selected.size === 0 && (
<div className="flex flex-1 flex-col items-center">
Select File
<br />
to Preview
</div>
)}
{f.selected.size === 1 && (
<div className="flex flex-col items-stretch justify-start flex-1 h-full">
<a
className={cx(
"border-b flex items-center justify-center relative overflow-auto",
css`
height: 50%;
img {
object-fit: cover;
object-position: center top;
}
`
)}
href={p.script.api._url(
`/_file${
f.path.startsWith("/") ? f.path : `/${f.path}`
}/${first?.name}`
)}
target="_blank"
>
{!local.no_image ? (
<>
{isImage(ext) ? (
<img
draggable={false}
className="absolute inset-0 w-full h-full"
src={p.script.api._url(
`/_img${
f.path.startsWith("/") ? f.path : `/${f.path}`
}/${first?.name}?w=500&f=jpg`
)}
alt={" thumbnail (500px)"}
onError={() => {
local.no_image = true;
local.render();
}}
/>
) : (
<div className="uppercase font-bold text-lg text-slate-300">
{ext}
</div>
)}
</>
) : (
<div className="uppercase font-bold text-lg text-slate-300">
NO IMG
</div>
)}
</a>
<div className="p-2 border-b flex justify-between">
<div>{first?.name}</div>
<div>{fileSize(first?.size || 0)}</div>
</div>
<input
type="text"
className="p-2 border-b flex justify-between"
value={p.script.api._url(
`/_file${
f.path.startsWith("/") ? f.path : `/${f.path}`
}/${first?.name}`,
false
)}
readOnly
onFocus={(e) => {
e.currentTarget.select();
}}
/>
</div>
)}
{f.selected.size > 1 && (
<div className="flex flex-col items-stretch flex-1">
<div className="pl-1">{f.selected.size} files selected:</div>
<div className="flex flex-col border-t">
{Object.entries(file_by_ext).map(([ext, file]) => {
return (
<div className="flex items-stretch border-b px-3" key={ext}>
<div className="min-w-[60px] border-r uppercase font-bold text-xs text-slate-600 flex items-center">
{ext}
</div>
<div className="flex-1 pl-1 items-center">
{file.length} file{file.length <= 1 ? "" : "s"}
</div>
</div>
);
})}
</div>
</div>
)}
</>
);
};
function fileSize(bytes: number): string {
const sizes = ["bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 bytes";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = i === 0 ? bytes : (bytes / Math.pow(1024, i)).toFixed(2);
return `${size} ${sizes[i]}`;
}

View File

@ -24,7 +24,7 @@ export const EdFileTop = () => {
for (let i = 0; i <= idx; i++) npath.push(paths[i]);
return (
<div
key={part}
key={`${part}-${idx}`}
className={breadClass(p)}
onClick={() => breadClick(p, "/" + npath.join("/"))}
>
@ -33,10 +33,23 @@ export const EdFileTop = () => {
);
})}
</div>
<div className={cx("p-1 border-t flex")}>
<div className={topClass(p)} {...getRootProps()}>
<input {...getInputProps()} />
Upload
<div className={cx("border-t flex justify-between")}>
<div className="flex p-1">
<div className={topClass(p)} {...getRootProps()}>
<input {...getInputProps()} />
Upload
</div>
</div>
<div
className={
"flex border-l items-center justify-center min-w-[30px] cursor-pointer hover:bg-blue-50"
}
onClick={() => {
f.preview = !f.preview;
p.render();
}}
>
{!f.preview ? <PreviewLeft /> : <PreviewRight />}
</div>
</div>
</div>
@ -56,3 +69,40 @@ const topClass = (p: PG, className?: string) =>
"border px-2 mr-1 rounded-sm cursor-pointer hover:bg-blue-100 hover:text-blue-700 border-slate-600 hover:border-blue-600 ",
className
);
const icon_size = 17;
const PreviewRight = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={icon_size}
height={icon_size}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="lucide lucide-panel-right-close"
viewBox="0 0 24 24"
>
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M15 3v18M8 9l3 3-3 3"></path>
</svg>
);
const PreviewLeft = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={icon_size}
height={icon_size}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="lucide lucide-panel-right-open"
viewBox="0 0 24 24"
>
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M9 3v18M16 15l-3-3 3-3"></path>
</svg>
);

View File

@ -104,6 +104,7 @@ const TreeItem: FC<{
f.path === path && "border-r-2 bg-blue-100 border-r-blue-700"
)}
onClick={() => {
f.selected.clear();
f.path = path;
p.render();
if (!f.expanded[path] || !f.entry[path]) {
@ -173,13 +174,21 @@ const TreeItem: FC<{
<MenuItem
label={"Delete"}
disabled={
!(f.entry[f.tree_ctx_path] && f.entry[f.tree_ctx_path]?.length === 0)
!(
f.entry[f.tree_ctx_path] &&
f.entry[f.tree_ctx_path]?.length === 0
)
}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!(f.entry[f.tree_ctx_path] && f.entry[f.tree_ctx_path]?.length === 0)) {
if (
!(
f.entry[f.tree_ctx_path] &&
f.entry[f.tree_ctx_path]?.length === 0
)
) {
alert("Can only delete empty folder!");
} else {
await p.script.api._raw(`/_file${f.tree_ctx_path}?del`);

View File

@ -3,7 +3,66 @@ import { PG } from "../../logic/ed-global";
import { reloadFileTree } from "./file-tree";
export const uploadFile = async (p: PG, files: File[]) => {
await p.script.api._raw(`/_upload?to=${p.ui.popup.file.path}`, ...files);
const f = p.ui.popup.file;
const promises: Promise<void>[] = [];
f.upload.started = true;
f.upload.progress = {};
const pr = f.upload.progress;
let folder_created = new Set<string>();
for (const file of files) {
let path = f.path;
let filename = (file as any).path ? (file as any).path : file.name;
if ((file as any).path) {
const arr = (file as any).path.split("/") as string[];
arr.pop();
path = join(path, ...arr);
const folder = arr.filter((e) => e).join("/");
if (folder) {
folder_created.add(folder);
}
}
if (!pr[filename]) {
pr[filename] = 0.1;
}
promises.push(
p.script.api._raw(`/_upload?to=${path}`, file, (arg: any) => {
pr[filename] = arg.progress;
p.render();
})
);
}
await Promise.all(promises);
alert(
`\
Uploaded Finished:
- ${files.length} files uploaded${
folder_created.size > 0
? `\n ${[...folder_created]
.map((e) => ` - Folder ${e} created.`)
.join("\n")}`
: ""
}`
);
f.upload.progress = {};
f.upload.started = false;
p.render();
reloadFileTree(p);
};
const join = (...arg: string[]) => {
let arr: string[] = [];
for (const s of arg) {
s.split("/").forEach((e) => {
arr.push(e);
});
}
arr = arr.filter((e) => !!e.trim());
return "/" + arg.join("/");
};

View File

@ -36,7 +36,7 @@ export const MenuItem = forwardRef<
return (
<button
{...props}
className="MenuItem flex justify-between items-center"
className="MenuItem flex justify-between items-center select-none"
ref={ref}
role="menuitem"
disabled={disabled}

BIN
bun.lockb

Binary file not shown.