wip fix prasi file

This commit is contained in:
Rizky 2024-02-22 04:55:08 +07:00
parent db45f936fc
commit 9ba10f56d1
16 changed files with 555 additions and 278 deletions

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,7 @@
"@minoru/react-dnd-treeview": "^3.4.4",
"@wojtekmaj/react-qr-svg": "^1.0.0",
"constrained-editor-plugin": "^1.3.0",
"react-resizable-panels": "^2.0.9",
"@monaco-editor/react": "^4.6.0",
"@paralleldrive/cuid2": "2.2.2",
"react-contenteditable": "^3.3.7",

View File

@ -47,9 +47,10 @@ export const apiProxy = (api_url: string) => {
if (api_ref) {
if (actionName === "_raw") {
const url = `${base_url}${rest.join("")}`;
const pathname = rest[0];
const url = `${base_url}${pathname}`;
const result = await fetchSendApi(url, rest);
const result = await fetchSendApi(url, rest.slice(1));
resolve(result);
return;
}

View File

@ -4,55 +4,57 @@
let w = (typeof window !== "undefined" ? window : null) as any;
export const fetchViaProxy = async (
url: string,
target_url: string,
data?: any,
_headers?: any
) => {
const headers = { ..._headers };
let body = data;
let body = null as any;
let isFile = false;
const formatSingle = async (data: any) => {
if (w !== null) {
if (!(data instanceof w.FormData || data instanceof w.File)) {
headers["content-type"] = "application/json";
} else {
if (data instanceof w.File) {
isFile = true;
let ab = await new Promise<ArrayBuffer | undefined>((resolve) => {
const reader = new FileReader();
reader.addEventListener("load", (e) => {
resolve(e.target?.result as ArrayBuffer);
});
reader.readAsArrayBuffer(data);
});
if (ab) {
data = new File([ab], data.name);
}
}
const files: File[] = [];
if (Array.isArray(data)) {
for (const item of data) {
if (item instanceof File) {
files.push(item);
isFile = true;
}
}
return data;
};
if (Array.isArray(data)) {
body = await Promise.all(data.map((e) => formatSingle(e)));
} else {
body = await formatSingle(data);
} else if (data instanceof File) {
isFile = true;
files.push(data);
}
if (!isFile) {
body = JSON.stringify(body);
body = JSON.stringify(data);
headers["content-type"] = "aplication/json";
} else {
const fd = new FormData();
for (const file of files) {
fd.append(file.name, file);
}
body = fd;
delete headers["content-type"];
headers["enctype"] = `multipart/form-data;`;
}
const base = new URL(url);
const to_url = new URL(target_url);
if (w !== null) {
const cur = new URL(location.href);
const cur_url = new URL(location.href);
let final_url = "";
if (cur.host === base.host) {
if (to_url.host === cur_url.host) {
final_url = to_url.toString();
} else {
final_url = `${cur_url.protocol}//${
cur_url.host
}/_proxy/${encodeURIComponent(to_url.toString())}`;
}
if (final_url) {
const res = await fetch(
base.pathname,
final_url,
data
? {
method: "POST",
@ -67,83 +69,7 @@ export const fetchViaProxy = async (
} catch (e) {
return raw;
}
} else {
if (
data instanceof File ||
(Array.isArray(data) && data[0] instanceof File)
) {
const target = new URL(url);
_headers["content-type"] = "multipart/form-data";
if (data instanceof File) {
const formData = new FormData();
formData.append("file", data);
const res = await fetch(target.pathname, {
body: formData,
method: "POST",
headers: _headers,
});
return await res.text();
} else {
const formData = new FormData();
let idx = 1;
for (const file of data) {
formData.append("file-" + idx++, file);
}
const res = await fetch(target.pathname, {
body: formData,
method: "POST",
headers: _headers,
});
return await res.text();
}
} else {
const res = await fetch(`${w.basehost ? w.basehost : ""}/_proxy`, {
method: "POST",
body: JSON.stringify([
{
url,
body,
headers,
},
]),
headers: { "content-type": "application/json" },
});
let text = "";
try {
text = await res.text();
return JSON.parse(text);
} catch (e) {
let formatted_body = null;
try {
formatted_body = JSON.stringify(JSON.parse(body), null, 2);
} catch (e) {}
console.warn(
`\n\n⚡ Failed to JSON.parse fetch result of ${url}:\n\n${JSON.stringify(
text
)} \n\nwith params:\n${formatted_body}`
);
return text;
}
}
}
} else {
const res = await fetch(
base,
data
? {
method: "POST",
body,
headers,
}
: undefined
);
const raw = await res.text();
try {
return JSON.parse(raw);
} catch (e) {
return raw;
}
}
return null;
};

View File

@ -8,6 +8,7 @@ import { IItem } from "../../../utils/types/item";
import { DCode, DComp, DPage, IRoot } from "../../../utils/types/root";
import { GenMetaP, IMeta as LogicMeta } from "../../vi/utils/types";
import { createRouter } from "radix3";
import { FEntry } from "../panel/file/type";
export type IMeta = LogicMeta;
export const EmptySite = {
@ -231,7 +232,22 @@ export const EDGlobal = {
open: {} as Record<string, string[]>,
},
popup: {
file: { enabled: false, open: true },
file: {
enabled: false,
open: true,
path: "/",
expanded: JSON.parse(
localStorage.getItem("panel-file-expanded") || "{}"
) as Record<string, string[]>,
entry: {} as Record<string, FEntry[]>,
tree: [] as NodeModel<FEntry>[],
renaming: "",
ctx_path: "",
ctx_menu_event: null as null | React.MouseEvent<
HTMLElement,
MouseEvent
>,
},
code: {
init: false,
open: false,

View File

@ -1,17 +1,16 @@
import { useCallback, useEffect } from "react";
import { useDropzone } from "react-dropzone";
import { useGlobal, useLocal } from "web-utils";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { useGlobal } from "web-utils";
import { apiProxy } from "../../../../base/load/api/api-proxy";
import { Modal } from "../../../../utils/ui/modal";
import { EDGlobal } from "../../logic/ed-global";
import { EdFileTree } from "./file-tree";
import { EdFileTree, reloadFileTree } from "./file-tree";
import { FEntry } from "./type";
import { EdFileTop } from "./file-top";
import { uploadFile } from "./file-upload";
export const EdFileBrowser = () => {
const p = useGlobal(EDGlobal, "EDITOR");
const local = useLocal({
entry: {} as Record<string, FEntry[]>,
});
useEffect(() => {
if (!p.script.api && p.site.config?.api_url) {
@ -19,20 +18,21 @@ export const EdFileBrowser = () => {
p.render();
}
if (!p.script.api) return () => {};
p.script.api._raw(`/_file/?dir`).then((e: FEntry[]) => {
if (Array.isArray(e)) {
local.entry = { "/": e };
p.ui.popup.file.entry = { "/": e };
p.ui.popup.file.enabled = true;
p.render();
}
});
reloadFileTree(p);
}, []);
const onDrop = useCallback((acceptedFiles: any[]) => {
console.log(acceptedFiles);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
onDrop: (files) => uploadFile(p, files),
noClick: true,
});
@ -74,44 +74,60 @@ export const EdFileBrowser = () => {
}
}}
>
<div
{...getRootProps()}
className={cx(
"bg-white select-none fixed inset-[50px] flex",
css`
outline: none;
`
)}
>
<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 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"
)}
minSize={8}
order={1}
className="border-r"
onResize={(e) => {
localStorage.setItem("panel-file-left", e + "");
}}
onContextMenu={(e) => {
e.preventDefault();
}}
>
<EdFileTree />
</Panel>
<PanelResizeHandle />
<Panel order={2}>
<div className="flex-1 flex h-full flex-col">
<EdFileTop />
<div className="flex flex-1 items-stretch">
<div className="flex min-w-[200px] border-r">
<EdFileTree entry={local.entry} />
</div>
<div>Moko</div>
</div>
<div
className={cx("flex-1 flex h-full outline-none relative")}
{...getRootProps()}
>
<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>
</div>
</Panel>
</PanelGroup>
</div>
</Modal>
</>

View File

@ -0,0 +1,58 @@
import { useGlobal } from "web-utils";
import { EDGlobal, PG } from "../../logic/ed-global";
import { useDropzone } from "react-dropzone";
import { uploadFile } from "./file-upload";
export const EdFileTop = () => {
const p = useGlobal(EDGlobal, "EDITOR");
const f = p.ui.popup.file;
const paths = f.path.split("/").filter((e) => e);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: (files) => uploadFile(p, files),
noDrag: true,
});
return (
<div className={cx("border-b flex flex-col items-stretch")}>
<div className={cx("flex p-1")}>
<div className={breadClass(p)} onClick={() => breadClick(p, "/")}>
/
</div>
{paths.map((part, idx) => {
const npath: string[] = [];
for (let i = 0; i <= idx; i++) npath.push(paths[i]);
return (
<div
key={part}
className={breadClass(p)}
onClick={() => breadClick(p, "/" + npath.join("/"))}
>
{part}
</div>
);
})}
</div>
<div className={cx("p-1 border-t flex")}>
<div className={topClass(p)} {...getRootProps()}>
<input {...getInputProps()} />
Upload
</div>
</div>
</div>
);
};
const breadClick = (p: PG, path: string) => {
p.ui.popup.file.path = path;
p.render();
};
const breadClass = (p: PG, className?: string) =>
cx("border px-2 mr-1 rounded-sm cursor-pointer hover:bg-blue-50", className);
const topClass = (p: PG, className?: string) =>
cx(
"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
);

View File

@ -1,52 +1,323 @@
import {
Tree as DNDTree,
MultiBackend,
NodeModel,
Tree as DNDTree,
getBackendOptions,
} from "@minoru/react-dnd-treeview";
import { DndProvider } from "react-dnd";
import { FEntry } from "./type";
import { FC } 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";
const Tree = DNDTree<FEntry>;
export const EdFileTree: FC<{ entry: Record<string, FEntry[]> }> = ({
entry,
}) => {
const tree: NodeModel<FEntry>[] = [];
for (const [path, entries] of Object.entries(entry)) {
const arr = path.split("/");
const name = arr.pop() || "/";
tree.push({ id: path, text: name, parent: arr.join("/") });
for (const e of entries) {
if (e.type === "dir") {
tree.push({
id: (path === "/" ? "" : path) + "/" + e.name,
text: e.name,
parent: path,
});
}
}
export const EdFileTree: FC<{}> = ({}) => {
const p = useGlobal(EDGlobal, "EDITOR");
if (!p.ui.popup.file.expanded[p.site.id]) {
p.ui.popup.file.expanded[p.site.id] = [];
}
return (
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={tree}
tree={p.ui.popup.file.tree}
dragPreviewRender={() => <></>}
rootId=""
initialOpen={true}
onDrop={async (newTree, opt) => {}}
render={(node, { depth, isOpen, onToggle, hasChild }) => (
<div
className={cx(css`
padding-left: ${(depth * 10) + 10}px;
`)}
>
{node.text}
</div>
)}
initialOpen={[...(p.ui.popup.file.expanded[p.site.id] || []), "/"]}
canDrop={(newTree, opt) => {
const to = opt.dropTargetId + "";
const from = opt.dragSourceId + "";
if (to.startsWith(from)) return false;
const from_arr = from.split("/").filter((e) => e);
const to_arr = to.split("/").filter((e) => e);
if (
from_arr.slice(0, from_arr.length - 1).join("/") ===
to_arr.join("/")
)
return false;
return true;
}}
onDrop={async (newTree, { dropTargetId, dragSourceId }) => {
await p.script.api._raw(`/_file${dragSourceId}?move=${dropTargetId}`);
await reloadFileTree(p);
}}
render={(
node,
{ depth, isOpen, onToggle, hasChild, isDragging, isDropTarget }
) => <TreeItem node={node} depth={depth} isDropTarget={isDropTarget} />}
></Tree>
</DndProvider>
);
};
const TreeItem: FC<{
node: NodeModel<FEntry>;
depth: number;
isDropTarget: boolean;
}> = ({ node, depth, isDropTarget }) => {
const p = useGlobal(EDGlobal, "EDITOR");
const path = node.id + "";
const f = p.ui.popup.file;
const local = useLocal({ renaming: node.text });
const expanded = f.expanded[p.site.id]?.includes(path);
return (
<div
className={cx(
"flex items-center space-x-1 flex-nowrap hover:bg-blue-50 py-[2px]",
isDropTarget && "bg-blue-500 text-white",
css`
padding-left: ${depth * 10 + 10}px;
`,
f.path === path && "border-r-2 bg-blue-100 border-r-blue-700"
)}
onClick={() => {
if (f.path === path) {
toggleDir(p, path);
}
f.path = path;
p.render();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (!expanded) {
toggleDir(p, path);
}
f.ctx_path = path;
f.ctx_menu_event = e;
p.render();
}}
>
{f.ctx_menu_event && (
<Menu
mouseEvent={f.ctx_menu_event}
onClose={() => {
setTimeout(() => {
f.ctx_path = "";
f.ctx_menu_event = null;
p.render();
}, 100);
}}
>
<MenuItem
label={"New Folder"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
f.ctx_menu_event = null;
p.render();
setTimeout(() => {
f.tree.push({
id: f.ctx_path + "/new_folder",
parent: f.ctx_path,
text: "new_folder",
data: {
name: "new_folder",
type: "dir",
size: 0,
},
});
f.expanded[p.site.id]?.push(f.ctx_path);
p.render();
f.path = f.ctx_path + "/new_folder";
f.renaming = f.ctx_path + "/new_folder";
f.ctx_path = "";
p.render();
});
}}
/>
<MenuItem
label={"Rename"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
f.path = f.ctx_path;
f.renaming = f.ctx_path;
p.render();
}}
/>
<MenuItem
label={"Delete"}
disabled={
!(f.entry[f.ctx_path] && f.entry[f.ctx_path]?.length === 0)
}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!(f.entry[f.ctx_path] && f.entry[f.ctx_path]?.length === 0)) {
alert("Can only delete empty folder!");
} else {
await p.script.api._raw(`/_file${f.ctx_path}?del`);
await reloadFileTree(p);
}
}}
/>
</Menu>
)}
<div
onClick={(e) => {
e.stopPropagation();
toggleDir(p, path);
}}
>
{expanded || path === "/" ? <FolderOpen /> : <Folder />}
</div>
{f.renaming === path ? (
<input
type="text"
spellCheck={false}
value={local.renaming}
autoFocus
onChange={(e) => {
local.renaming = e.currentTarget.value.replace(/\W/gi, "_");
local.render();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
onFocus={(e) => {
e.currentTarget.select();
}}
onBlur={async () => {
if (local.renaming !== node.text) {
node.text = local.renaming;
const res = await p.script.api._raw(
`/_file${f.renaming}?rename=${local.renaming}`
);
if (res && res.newname) {
f.path = res.newname;
}
await reloadFileTree(p);
}
f.renaming = "";
p.render();
}}
className="flex-1 border border-blue-500 outline-none"
/>
) : (
<div className="flex-1 text-ellipsis truncate">{node.text}</div>
)}
</div>
);
};
const size = 14;
const FolderOpen = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<path d="M6 14l1.5-2.9A2 2 0 019.24 10H20a2 2 0 011.94 2.5l-1.54 6a2 2 0 01-1.95 1.5H4a2 2 0 01-2-2V5a2 2 0 012-2h3.9a2 2 0 011.69.9l.81 1.2a2 2 0 001.67.9H18a2 2 0 012 2v2"></path>
</svg>
);
const Folder = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill="none"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M20 20a2 2 0 002-2V8a2 2 0 00-2-2h-7.9a2 2 0 01-1.69-.9L9.6 3.9A2 2 0 007.93 3H4a2 2 0 00-2 2v13a2 2 0 002 2z"></path>
</svg>
);
const toggleDir = (p: PG, path: string) => {
if (path === "/") return;
const expanded = p.ui.popup.file.expanded[p.site.id];
if (expanded) {
if (expanded.includes(path)) {
p.ui.popup.file.expanded[p.site.id] = expanded.filter((e) => e !== path);
} else {
p.ui.popup.file.expanded[p.site.id] = [...expanded, path];
}
}
localStorage.setItem(
"panel-file-expanded",
JSON.stringify(p.ui.popup.file.expanded)
);
reloadFileTree(p);
};
export const reloadFileTree = async (p: PG) => {
const exp = p.ui.popup.file.expanded[p.site.id];
const e = await p.script.api._raw(`/_file/?dir`);
if (Array.isArray(e)) {
p.ui.popup.file.entry = { "/": e };
}
if (exp) {
for (const [k, v] of Object.entries(p.ui.popup.file.entry)) {
if (p.ui.popup.file.entry[k] && !exp.includes(k) && k !== "/") {
delete p.ui.popup.file.entry[k];
}
}
const promises = [];
for (const e of exp) {
if (e) {
if (!p.ui.popup.file.entry[e]) {
promises.push(
p.script.api._raw(`/_file${e}/?dir`).then((fe: FEntry[]) => {
if (Array.isArray(fe)) {
p.ui.popup.file.entry[e] = fe;
}
})
);
}
}
}
await Promise.all(promises);
}
const tree: NodeModel<FEntry>[] = p.ui.popup.file.tree;
tree.length = 0;
tree.push({ id: "/", text: "/", parent: "" });
const added = new Set<string>(["/"]);
for (const [path, entries] of Object.entries(p.ui.popup.file.entry)) {
const arr = path.split("/");
for (const e of entries) {
let id = path + (path.endsWith("/") ? "" : "/") + e.name;
if (!id.startsWith("/")) id = "/" + id;
if (e.type === "dir" && !added.has(id)) {
tree.push({
id,
text: e.name,
parent: path || "/",
});
}
}
}
p.ui.popup.file.tree = tree.sort(
(a, b) => (a.id + "").length - (b.id + "").length
);
p.render();
};

View File

@ -0,0 +1,6 @@
import { fetchViaProxy } from "../../../../base/load/proxy";
import { PG } from "../../logic/ed-global";
export const uploadFile = async (p: PG, files: File[]) => {
await p.script.api._raw(`/_upload?to=${p.ui.popup.file.path}`, ...files);
};

View File

@ -42,6 +42,7 @@ export const viLoadLegacy = async (vi: {
let api_url = vi.site.api_url;
if (!api_url) api_url = ((site.config as any) || {}).api_url || "";
if (!api_url) return;
try {
const apiURL = new URL(api_url);

BIN
bun.lockb

Binary file not shown.

View File

@ -1,3 +1,4 @@
import { apiContext } from "service-srv";
import { gzipAsync } from "../../../app/srv/ws/sync/entity/zlib";
import { CORS_HEADERS } from "../server/serve-api";
import brotliPromise from "brotli-wasm";
@ -6,44 +7,30 @@ const brotli = await brotliPromise;
export const _ = {
url: "/_proxy/*",
async api(arg: {
url: string;
method: "POST" | "GET";
headers: any;
body: any;
}) {
if ((!!arg && !arg.url) || !arg) return new Response(null, { status: 403 });
raw: true,
async api() {
const { req } = apiContext(this);
const res = await fetch(
arg.url,
arg.body
? {
method: arg.method || "POST",
headers: arg.headers,
body: arg.body,
}
: {
headers: arg.headers,
}
);
let body: any = null;
const headers: any = {};
res.headers.forEach((v, k) => {
headers[k] = v;
});
body = await res.arrayBuffer();
if (headers["content-encoding"] === "gzip") {
body = await gzipAsync(new Uint8Array(body));
} else if (headers["content-encoding"] === "br") {
body = brotli.decompress(new Uint8Array(body));
delete headers["content-encoding"];
} else {
delete headers["content-encoding"];
try {
const url = new URL(decodeURIComponent(req.params["_"]));
const body = await req.arrayBuffer();
return await fetch(url, {
method: req.method || "POST",
headers: req.headers,
body,
});
} catch (e: any) {
console.error(e);
new Response(
JSON.stringify({
status: "failed",
reason: e.message,
}),
{
status: 403,
headers: { "content-type": "application/json" },
}
);
}
return new Response(body, { headers: { ...headers, ...CORS_HEADERS } });
},
};

View File

@ -18,6 +18,7 @@ export const prepareApiRoutes = async () => {
const route = {
url: api._.url,
args,
raw: !!api._.raw,
fn: api._.api,
path: importPath.substring((root || path).length + 1),
};

View File

@ -23,8 +23,7 @@ export const createServer = async () => {
g.server = Bun.serve({
port: g.port,
maxRequestBodySize: 9999999,
development: true,
maxRequestBodySize: 1024 * 1024 * 128,
websocket: await serveWS(wsHandler),
async fetch(req, server) {
const url = new URL(req.url);

View File

@ -43,7 +43,7 @@ export const serveAPI = {
return params[e];
});
if (req.method !== "GET") {
if (req.method !== "GET" && !found.raw) {
if (req.method === "OPTIONS") {
return new Response("OK", {
headers: CORS_HEADERS,
@ -57,7 +57,7 @@ export const serveAPI = {
const text = await req.text();
const json = JSON.parse(text, replacer);
if (typeof json === "object") {
if (typeof json === "object" && !!json) {
if (Array.isArray(json)) {
args = json;
for (let i = 0; i < json.length; i++) {

View File

@ -14,6 +14,7 @@ import { dbProxy } from "../../../app/web/src/base/load/db/db-proxy";
type SingleRoute = {
url: string;
args: string[];
raw: boolean;
fn: (...arg: any[]) => Promise<any>;
path: string;
};