Merge pull request #202 from 11cafe/share-cloud

Share cloud
This commit is contained in:
Weixuan Fu
2024-02-20 00:37:07 +08:00
committed by GitHub
9 changed files with 317 additions and 130 deletions
+34 -10
View File
@@ -2,21 +2,25 @@ import React, { useState, useRef, useEffect } from "react";
import { Box, Button, Card } from "@chakra-ui/react";
import { IconChevronDown } from "@tabler/icons-react";
export interface CustomSelectorOption {
export interface CustomSelectorOption<T> {
label: string;
value: string;
value: T;
icon?: React.ReactElement;
}
type Props = {
options: CustomSelectorOption[];
type Props<T> = {
options: CustomSelectorOption<T>[];
value: T;
onChange: (value: T) => void;
};
export default function CustomSelector({ options }: Props) {
export default function CustomSelector<T>({
options,
value,
onChange,
}: Props<T>) {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [selectedOption, setSelectedOption] = useState<
CustomSelectorOption | undefined
>(options[0]);
const selectedOption = options.find((option) => option.value === value);
useEffect(() => {
const handleClickOutside = (event: any) => {
@@ -41,6 +45,25 @@ export default function CustomSelector({ options }: Props) {
>
{selectedOption?.label}
</Button>
{/* {isOpen && (
<Menu isOpen={true}>
<MenuList
minWidth={150}
zIndex={1000}
onMouseEnter={onOpen}
onMouseLeave={delayedClose}
>
<MenuItem
onClick={saveCurWorkflow}
icon={<IconDeviceFloppy size={20} />}
iconSpacing={1}
command={saveShortcut}
>
Save
</MenuItem>
</MenuList>
</Menu>
)} */}
{isOpen && (
<Card
gap={4}
@@ -50,13 +73,14 @@ export default function CustomSelector({ options }: Props) {
borderWidth="1px"
p="2"
position="absolute"
zIndex={100}
>
{options.map((option) => (
<Button
key={option.value}
key={option.label}
onClick={() => {
setSelectedOption(option);
setIsOpen(false);
onChange(option.value);
}}
leftIcon={option.icon}
justifyContent="flex-start"
+4 -2
View File
@@ -1,5 +1,6 @@
import {
ChangeEvent,
lazy,
useCallback,
useContext,
useEffect,
@@ -37,9 +38,9 @@ import { workflowsTable, userSettingsTable } from "../db-tables/WorkspaceDB";
import { WorkspaceContext } from "../WorkspaceContext";
import { VersionHistoryDrawer } from "./VersionHistoryDrawer";
import { Workflow } from "../types/dbTypes";
import ShareDialog from "../share/ShareDialog";
import CustomMenu from "./CustomMenu";
import CreateVersionDialog from "./CreateVersionDialog";
const ShareDialog = lazy(() => import("../share/ShareDialog"));
export default function DropdownTitle() {
const {
@@ -134,7 +135,8 @@ export default function DropdownTitle() {
height={"29px"}
aria-label="menu"
size={"sm"}
colorScheme="teal"
colorScheme="gray"
backgroundColor={"gray.700"}
onClick={onOpen}
onMouseEnter={onOpen}
onMouseLeave={delayedClose}
+49
View File
@@ -0,0 +1,49 @@
import { useRef, useState } from "react";
import CustomMenu from "./CustomMenu";
import { Box, Menu, MenuList } from "@chakra-ui/react";
type Props = {
menuButton: React.ReactElement;
menuContent: React.ReactElement;
onClose?: () => void;
};
export default function HoverMenu({ menuButton, menuContent, onClose }: Props) {
const closeTimeoutId = useRef<number>();
const [isOpen, setIsOpen] = useState(false);
const delayedClose = () => {
closeTimeoutId.current = setTimeout(() => setIsOpen(false), 400); // delay of 300ms
};
const onOpen = () => {
setIsOpen(true);
clearTimeout(closeTimeoutId.current);
closeTimeoutId.current = undefined;
};
return (
<CustomMenu
isOpen={isOpen}
onClose={delayedClose}
menuButton={
<Box
aria-label="menu"
onClick={onOpen}
onMouseEnter={onOpen}
onMouseLeave={delayedClose}
>
{menuButton}
</Box>
}
options={
<Menu isOpen={true}>
<MenuList
minWidth={150}
zIndex={1000}
onMouseEnter={onOpen}
onMouseLeave={delayedClose}
>
{menuContent}
</MenuList>
</Menu>
}
/>
);
}
+82 -101
View File
@@ -1,31 +1,28 @@
import {
Box,
Button,
Flex,
HStack,
Input,
InputGroup,
InputLeftAddon,
InputRightAddon,
Link,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Stack,
Text,
useToast,
} from "@chakra-ui/react";
import { Workflow, WorkflowVersion } from "../types/dbTypes";
import { Workflow, WorkflowPrivacy, WorkflowVersion } from "../types/dbTypes";
import { useEffect, useRef, useState } from "react";
import CustomDropdown, {
CustomSelectorOption,
} from "../components/CustomSelector";
import {
IconCloud,
IconCopy,
IconExternalLink,
IconLink,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import CustomSelector from "../components/CustomSelector";
import { IconCloud, IconCopy, IconExternalLink } from "@tabler/icons-react";
import {
userSettingsTable,
workflowVersionsTable,
@@ -35,36 +32,14 @@ import {
import { api } from "/scripts/api.js";
// @ts-ignore
import { app } from "/scripts/app.js";
import { generateRandomKey, getNodeDefs, privacyOptions } from "./shareUtils";
import { formatTimestamp } from "../utils";
import ShareDialogWorkflowVersionRadio from "./ShareDialogWorkflowVersionRadio";
interface Props {
onClose: () => void;
}
type CreatedCloudflowVersionEvent = {
createdVer: WorkflowVersion;
localWorkflowID: string;
localVersionID: string;
};
function generateRandomKey(length: number) {
// Generate a random array of bytes
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
// Convert the bytes to a hex string
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
const privacyOptions: CustomSelectorOption[] = [
{ label: "Private", value: "PRIVATE", icon: <IconLock /> },
{
label: "Anyone with the link can access",
value: "UNLISTED",
icon: <IconLink />,
},
{ label: "Public", value: "PUBLIC", icon: <IconWorld /> },
];
export default function ShareDialog({ onClose }: Props) {
const [versionName, setVersionName] = useState(
"version " + getCurDateString(),
@@ -72,6 +47,10 @@ export default function ShareDialog({ onClose }: Props) {
const [localVersions, setLocalVersions] = useState<WorkflowVersion[]>([]);
const [loading, setLoading] = useState(false);
const [cloudHost, setCloudHost] = useState("");
const [privacy, setPrivacy] = useState<WorkflowPrivacy>("PRIVATE");
const [selectedVersion, setSelectedVersion] = useState<
string | "new_version"
>("new_version");
const cloudHostRef = useRef("");
const [workflow, setWorkflow] = useState<Workflow>();
const toast = useToast();
@@ -81,11 +60,9 @@ export default function ShareDialog({ onClose }: Props) {
event.origin !== cloudHostRef.current ||
detail.type !== "share_workflow_success"
) {
console.log("event.origin !== cloudHost", event.origin !== cloudHost);
return;
}
console.log("share_workflow_success", detail);
const localID = detail.localWorkflowID;
const localVerID = detail.localVersionID;
const cloudVersionID = detail.version.id;
@@ -95,14 +72,20 @@ export default function ShareDialog({ onClose }: Props) {
localID &&
(await workflowsTable?.updateFlow(localID, {
cloudID: cloudID,
cloudURL: cloudHost + "/workflow/" + cloudID,
cloudURL: cloudHostRef.current + "/workflow/" + cloudID,
}));
localVerID &&
cloudVersionID &&
(await workflowVersionsTable?.update(localVerID, {
cloudID: cloudVersionID,
cloudURL: cloudHostRef.current + "/workflow_ver/" + cloudVersionID,
}));
loadData();
window.open(
cloudHostRef.current + "/workflow_ver/" + cloudVersionID,
"_blank",
);
setLoading(false);
};
useEffect(() => {
loadData();
@@ -111,18 +94,7 @@ export default function ShareDialog({ onClose }: Props) {
window.removeEventListener("message", handleShareWorkflowSuccess);
};
}, []);
const getNodeDefs = () => {
const allNodes = app.graph._nodes;
const nodeDefs = {};
for (let n of allNodes) {
// @ts-ignore
if (n.type in LiteGraph.registered_node_types) {
// @ts-ignore
nodeDefs[n.type] = LiteGraph.registered_node_types[n.type].nodeData;
}
}
return nodeDefs;
};
const loadData = async () => {
const host =
import.meta.env.MODE === "production"
@@ -161,14 +133,19 @@ export default function ShareDialog({ onClose }: Props) {
? ((await userSettingsTable?.getSetting("cloudHost")) as string)
: "http://localhost:3000";
const nodeDefs = getNodeDefs();
const newVer = await workflowVersionsTable?.add({
workflowID: workflow!.id,
name: versionName,
createTime: Date.now(),
json: workflow!.json,
});
if (!newVer) {
alert("Failed to create new version, please try again.");
let version: WorkflowVersion | undefined;
if (selectedVersion === "new_version") {
version = await workflowVersionsTable?.add({
workflowID: workflow!.id,
name: versionName,
createTime: Date.now(),
json: JSON.stringify(app.graph.serialize()),
});
} else {
version = await workflowVersionsTable?.get(selectedVersion);
}
if (!version) {
alert(`Failed to find version: ${selectedVersion}, please try again.`);
setLoading(false);
return;
}
@@ -182,21 +159,16 @@ export default function ShareDialog({ onClose }: Props) {
"Share Workflow",
"width=800,height=800",
);
setLoading(false);
const handleChildReady = (event: MessageEvent) => {
if (event.origin === host && event.data === "child_ready") {
const curWorkflow = workflowsTable?.curWorkflow;
console.log("Child window is ready.inputdata", {
workflow: curWorkflow,
version: newVer,
nodeDefs: nodeDefs,
});
// Send data to the new window after it loads
sharePopup!.postMessage(
{
workflow: curWorkflow,
version: newVer,
version: version,
nodeDefs: nodeDefs,
privacy: privacy,
},
host,
);
@@ -214,7 +186,13 @@ export default function ShareDialog({ onClose }: Props) {
<ModalHeader>Share "{workflow?.name}"</ModalHeader>
<ModalBody pb={10}>
<Stack gap={6}>
<CustomDropdown options={privacyOptions} />
<CustomSelector<WorkflowPrivacy>
options={privacyOptions}
value={privacy}
onChange={(val) => {
setPrivacy(val);
}}
/>
{cloudWorkflowID && (
<HStack spacing={2} color="teal.400">
@@ -245,40 +223,43 @@ export default function ShareDialog({ onClose }: Props) {
</Button>
</HStack>
)}
<HStack>
<Text>New Version</Text>
<Input
value={versionName}
maxWidth={440}
onChange={(e) => {
setVersionName(e.target.value);
}}
/>
</HStack>
{localVersions.slice(0, 4).map((ver) => {
return (
<HStack key={ver.id} spacing={4} alignItems={"center"} mb={3}>
<Text>{ver.name}</Text>
{ver.cloudID && (
<a
href={cloudHost + "/workflow_ver/" + ver.cloudID}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "none" }}
>
<Button
size={"xs"}
colorScheme="teal"
leftIcon={<IconCloud />}
rightIcon={<IconExternalLink size={18} />}
>
Shared
</Button>
</a>
)}
<Text>Choose a version to share:</Text>
<RadioGroup
gap={4}
onChange={(val) => {
setSelectedVersion(val);
}}
value={selectedVersion}
>
<Stack>
<HStack mb={5} alignItems={"center"}>
<Radio key={"new_version"} value="new_version" />
<Input
value={versionName}
width={"60%"}
onFocus={() => {
setSelectedVersion("new_version");
}}
onChange={(e) => {
setVersionName(e.target.value);
}}
/>
<Flex color="green">
<Text>New version</Text>
</Flex>
</HStack>
);
})}
{localVersions.slice(0, 4).map((ver) => {
return (
<ShareDialogWorkflowVersionRadio
key={ver.id}
version={ver}
cloudHost={cloudHost}
/>
);
})}
</Stack>
</RadioGroup>
</Stack>
<HStack justifyContent={"flex-end"} mt={16}>
<Button colorScheme="teal" onClick={onShare} isDisabled={loading}>
@@ -0,0 +1,46 @@
import { Button, HStack, Radio, Text } from "@chakra-ui/react";
import { WorkflowVersion } from "../types/dbTypes";
import { formatTimestamp } from "../utils";
import { IconCloud, IconExternalLink } from "@tabler/icons-react";
export default function ShareDialogWorkflowVersionRadio({
version,
cloudHost,
}: {
version: WorkflowVersion;
cloudHost: string;
}) {
const ver = version;
const content = (
<HStack spacing={4} alignItems={"center"}>
<Text>{ver.name}</Text>
<Text color={"GrayText"}>{formatTimestamp(ver.createTime, false)}</Text>
{ver.cloudID && (
<a
href={cloudHost + "/workflow_ver/" + ver.cloudID}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "none" }}
>
<Button
size={"xs"}
colorScheme="teal"
leftIcon={<IconCloud />}
rightIcon={<IconExternalLink size={18} />}
>
Shared
</Button>
</a>
)}
</HStack>
);
if (!version.cloudID) {
return (
<Radio key={ver.id} value={ver.id} mb={5}>
{content}
</Radio>
);
}
return <>{content}</>;
}
+50 -14
View File
@@ -1,22 +1,58 @@
import { Button } from "@chakra-ui/react";
import { IconCloud } from "@tabler/icons-react";
import { Button, Link, HStack, IconButton } from "@chakra-ui/react";
import { IconCloud, IconCopy } from "@tabler/icons-react";
import { workflowsTable } from "../db-tables/WorkspaceDB";
import { useContext, useEffect, useState } from "react";
import { WorkspaceContext } from "../WorkspaceContext";
import HoverMenu from "../components/HoverMenu";
export function SharedTopbarButton({}) {
const isCloudflow = workflowsTable?.curWorkflow?.cloudID;
if (!isCloudflow) return null;
const [cloudURL, setCloudURL] = useState<string>();
const { curFlowID } = useContext(WorkspaceContext);
useEffect(() => {
if (curFlowID) {
workflowsTable?.get(curFlowID).then((flow) => {
if (flow?.cloudID) {
setCloudURL(flow.cloudURL);
} else {
setCloudURL(undefined);
}
});
}
}, [curFlowID]);
if (!cloudURL) return null;
return (
<>
<Button
onClick={() => {
window.open(workflowsTable?.curWorkflow?.cloudURL);
}}
aria-label={"Shared"}
size={"sm"}
leftIcon={<IconCloud />}
>
Public
</Button>
<HoverMenu
menuButton={
<Button
onClick={() => {
window.open(workflowsTable?.curWorkflow?.cloudURL);
}}
aria-label={"Shared"}
size={"xs"}
iconSpacing={"2px"}
leftIcon={<IconCloud />}
height={"26px"}
px={1}
>
Public
</Button>
}
menuContent={
<HStack width={"200px"} p={1} color={"blue.400"}>
<Link href={cloudURL} noOfLines={1} isExternal>
{cloudURL}
</Link>
<IconButton
size={"xs"}
variant={"ghost"}
icon={<IconCopy size={19} />}
aria-label="copy link"
></IconButton>
</HStack>
}
/>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { IconLink, IconLock, IconWorld } from "@tabler/icons-react";
import { CustomSelectorOption } from "../components/CustomSelector";
import { WorkflowPrivacy } from "../types/dbTypes";
// @ts-expect-error
import { app } from "/scripts/app.js";
export function generateRandomKey(length: number) {
// Generate a random array of bytes
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
// Convert the bytes to a hex string
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
export const privacyOptions: CustomSelectorOption<WorkflowPrivacy>[] = [
{ label: "Private", value: "PRIVATE", icon: <IconLock /> },
{
label: "Anyone with the link can access",
value: "UNLISTED",
icon: <IconLink />,
},
{ label: "Public", value: "PUBLIC", icon: <IconWorld /> },
];
export const getNodeDefs = () => {
const allNodes = app.graph._nodes;
// console.log("allNodes", allNodes);
// console.log(
// "LiteGraph.registered_node_types",
// LiteGraph.registered_node_types,
// );
const nodeDefs = {};
for (let n of allNodes) {
// @ts-ignore
if (n.type in LiteGraph.registered_node_types) {
// TODO: js only nodes has no nodeData field, need to handle this....
// @ts-ignore
nodeDefs[n.type] = LiteGraph.registered_node_types[n.type].nodeData;
}
}
return nodeDefs;
};
+2 -2
View File
@@ -16,7 +16,6 @@ export interface Workflow extends SortableItem {
coverMediaPath?: string;
cloudID?: string;
cloudURL?: string;
privacy?: WorkflowPrivacy;
}
export interface TableBaseModel {
@@ -60,6 +59,7 @@ export type WorkflowVersion = {
json: string;
createTime: number;
cloudID?: string;
cloudURL?: string;
nodeDefs?: string; //for cloud workflow version
};
@@ -121,6 +121,6 @@ export enum EFlowOperationType {
DELETE = "delete",
}
type WorkflowPrivacy = "PUBLIC" | "PRIVATE" | "UNLISTED";
export type WorkflowPrivacy = "PUBLIC" | "PRIVATE" | "UNLISTED";
export const WORKSPACE_INDEXDB_NAME = "comfyui_workspace_db";
+5 -1
View File
@@ -106,6 +106,7 @@ function isValidFileName(fileName: string) {
export function formatTimestamp(
unixTimestamp: number,
showHourMinue: boolean = true,
showSec: boolean = false,
) {
// Create a new Date object from the UNIX timestamp
@@ -119,7 +120,10 @@ export function formatTimestamp(
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
// Format the date and time string
const res = `${month}-${day}-${year} ${hours}:${minutes}`;
const res = `${month}-${day}-${year}`;
if (showHourMinue) {
return res + ` ${hours}:${minutes}`;
}
if (showSec) {
return res + `:${seconds}`;
}