diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 44903f0a6..def3694ed 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -24,6 +24,7 @@ "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "latest", + "@lottiefiles/dotlottie-react": "^0.13.0", "@manaflair/redux-batch": "^1.0.0", "@rjsf/antd": "^5.21.2", "@rjsf/core": "^5.21.2", diff --git a/client/packages/lowcoder/src/api/iconFlowApi.ts b/client/packages/lowcoder/src/api/iconFlowApi.ts new file mode 100644 index 000000000..ba6e6bea0 --- /dev/null +++ b/client/packages/lowcoder/src/api/iconFlowApi.ts @@ -0,0 +1,163 @@ +import Api from "api/api"; +import axios, { AxiosInstance, AxiosPromise, AxiosRequestConfig } from "axios"; +import { calculateFlowCode } from "./apiUtils"; + +export interface SearchParams { + query: string; + asset: string; + per_page: number; + page: 1; + sort: string; + formats?: string; + price?: string; +} + +export type ResponseType = { + response: any; +}; + +const lcHeaders = { + "Lowcoder-Token": calculateFlowCode(), + "Content-Type": "application/json" +}; + +let axiosIns: AxiosInstance | null = null; + +const getAxiosInstance = (clientSecret?: string) => { + if (axiosIns && !clientSecret) { + return axiosIns; + } + + const headers: Record = { + "Content-Type": "application/json", + }; + + const apiRequestConfig: AxiosRequestConfig = { + baseURL: "https://api-service.lowcoder.cloud/api/flow", + headers, + }; + + axiosIns = axios.create(apiRequestConfig); + return axiosIns; +} + +class IconFlowApi extends Api { + + static async secureRequest(body: any, timeout: number = 6000): Promise { + let response; + const axiosInstance = getAxiosInstance(); + + // Create a cancel token and set timeout for cancellation + const source = axios.CancelToken.source(); + const timeoutId = setTimeout(() => { + source.cancel("Request timed out."); + }, timeout); + + // Request configuration with cancel token + const requestConfig: AxiosRequestConfig = { + method: "POST", + withCredentials: true, + data: body, + cancelToken: source.token, // Add cancel token + }; + + try { + response = await axiosInstance.request(requestConfig); + } catch (error) { + if (axios.isCancel(error)) { + // Retry once after timeout cancellation + try { + // Reset the cancel token and retry + const retrySource = axios.CancelToken.source(); + const retryTimeoutId = setTimeout(() => { + retrySource.cancel("Retry request timed out."); + }, 20000); + + response = await axiosInstance.request({ + ...requestConfig, + cancelToken: retrySource.token, + }); + + clearTimeout(retryTimeoutId); + } catch (retryError) { + console.warn("Error at Secure Flow Request. Retry failed:", retryError); + throw retryError; + } + } else { + console.warn("Error at Secure Flow Request:", error); + throw error; + } + } finally { + clearTimeout(timeoutId); // Clear the initial timeout + } + + return response; + } + +} + +export const searchAssets = async (searchParameters : SearchParams) => { + const apiBody = { + path: "webhook/scout/search-asset", + data: searchParameters, + method: "post", + headers: lcHeaders + }; + try { + const result = await IconFlowApi.secureRequest(apiBody); + return result?.data?.response?.items?.total > 0 ? result.data.response.items as any : null; + } catch (error) { + console.error("Error searching Design Assets:", error); + throw error; + } +}; + +export const getAssetLinks = async (uuid: string, params: Record) => { + const apiBody = { + path: "webhook/scout/get-asset-links", + data: {"uuid" : uuid, "params" : params}, + method: "post", + headers: lcHeaders + }; + try { + const result = await IconFlowApi.secureRequest(apiBody); + + return result?.data?.response?.download?.url.length > 0 ? result.data.response.download as any : null; + } catch (error) { + console.error("Error searching Design Assets:", error); + throw error; + } +}; + + +/* + +static async search(params: SearchParams): Promise { + let response; + try { + response = await getAxiosInstance().request({ + url: '/v3/search', + method: "GET", + withCredentials: false, + params: { + ...params, + }, + }); + } catch (error) { + console.error(error); + } + return response?.data.response.items; + } + + static async download(uuid: string, params: Record): Promise { + const response = await getAxiosInstance(clientSecret).request({ + url: `/v3/items/${uuid}/api-download?format=${params.format}`, + method: "POST", + withCredentials: false, + }); + return response?.data.response.download; + } + +*/ + +export default IconFlowApi; \ No newline at end of file diff --git a/client/packages/lowcoder/src/api/iconscoutApi.ts b/client/packages/lowcoder/src/api/iconscoutApi.ts new file mode 100644 index 000000000..0ad5bf256 --- /dev/null +++ b/client/packages/lowcoder/src/api/iconscoutApi.ts @@ -0,0 +1,15 @@ +import Api from "api/api"; +import axios from "axios"; + +export type ResponseType = { + response: any; +}; + +class IconScoutApi extends Api { + static async downloadAsset(url: string): Promise { + const response = await axios.get(url, {responseType: 'blob'}) + return response?.data; + } +} + +export default IconScoutApi; \ No newline at end of file diff --git a/client/packages/lowcoder/src/api/subscriptionApi.ts b/client/packages/lowcoder/src/api/subscriptionApi.ts index 6bfcdb259..7e19c8f19 100644 --- a/client/packages/lowcoder/src/api/subscriptionApi.ts +++ b/client/packages/lowcoder/src/api/subscriptionApi.ts @@ -1,11 +1,6 @@ import Api from "api/api"; import axios, { AxiosInstance, AxiosRequestConfig, CancelToken } from "axios"; -import { useDispatch, useSelector } from "react-redux"; -import { useEffect, useState} from "react"; import { calculateFlowCode } from "./apiUtils"; -import { fetchGroupsAction, fetchOrgUsersAction } from "redux/reduxActions/orgActions"; -import { getOrgUsers } from "redux/selectors/orgSelectors"; -import { AppState } from "@lowcoder-ee/redux/reducers"; import type { LowcoderNewCustomer, LowcoderSearchCustomer, diff --git a/client/packages/lowcoder/src/comps/comps/iconComp.tsx b/client/packages/lowcoder/src/comps/comps/iconComp.tsx index f93b0eb2a..9a8eb1790 100644 --- a/client/packages/lowcoder/src/comps/comps/iconComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/iconComp.tsx @@ -30,6 +30,8 @@ import { } from "../controls/eventHandlerControl"; import { useContext } from "react"; import { EditorContext } from "comps/editorState"; +import { AssetType, IconscoutControl } from "@lowcoder-ee/comps/controls/iconscoutControl"; +import { dropdownControl } from "../controls/dropdownControl"; const Container = styled.div<{ $style: IconStyleType | undefined; @@ -61,10 +63,17 @@ const Container = styled.div<{ const EventOptions = [clickEvent] as const; +const ModeOptions = [ + { label: "Standard", value: "standard" }, + { label: "Asset Library", value: "asset-library" }, +] as const; + const childrenMap = { style: styleControl(IconStyle,'style'), animationStyle: styleControl(AnimationStyle,'animationStyle'), + sourceMode: dropdownControl(ModeOptions, "standard"), icon: withDefault(IconControl, "/icon:antd/homefilled"), + iconScoutAsset: IconscoutControl(AssetType.ICON), autoHeight: withDefault(AutoHeightControl, "auto"), iconSize: withDefault(NumberControl, 20), onEvent: eventHandlerControl(EventOptions), @@ -103,7 +112,10 @@ const IconView = (props: RecordConstructorToView) => { }} onClick={() => props.onEvent("click")} > - {props.icon} + { props.sourceMode === 'standard' + ? props.icon + : + } )} > @@ -117,11 +129,17 @@ let IconBasicComp = (function () { .setPropertyViewFn((children) => ( <>
- {children.icon.propertyView({ + { children.sourceMode.propertyView({ + label: "", + radioButton: true + })} + {children.sourceMode.getView() === 'standard' && children.icon.propertyView({ label: trans("iconComp.icon"), IconType: "All", })} - + {children.sourceMode.getView() === 'asset-library' && children.iconScoutAsset.propertyView({ + label: trans("button.icon"), + })}
{["logic", "both"].includes(useContext(EditorContext).editorModeStatus) && ( diff --git a/client/packages/lowcoder/src/comps/comps/imageComp.tsx b/client/packages/lowcoder/src/comps/comps/imageComp.tsx index d78a21d20..1806399e2 100644 --- a/client/packages/lowcoder/src/comps/comps/imageComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/imageComp.tsx @@ -12,7 +12,7 @@ import { withExposingConfigs, } from "../generators/withExposing"; import { RecordConstructorToView } from "lowcoder-core"; -import { useEffect, useRef, useState } from "react"; +import { ReactElement, useEffect, useRef, useState } from "react"; import _ from "lodash"; import ReactResizeDetector from "react-resize-detector"; import { styleControl } from "comps/controls/styleControl"; @@ -35,6 +35,8 @@ import { useContext } from "react"; import { EditorContext } from "comps/editorState"; import { StringControl } from "../controls/codeControl"; import { PositionControl } from "comps/controls/dropdownControl"; +import { dropdownControl } from "../controls/dropdownControl"; +import { AssetType, IconscoutControl } from "../controls/iconscoutControl"; const Container = styled.div<{ $style: ImageStyleType | undefined, @@ -111,6 +113,10 @@ const getStyle = (style: ImageStyleType) => { }; const EventOptions = [clickEvent] as const; +const ModeOptions = [ + { label: "URL", value: "standard" }, + { label: "Asset Library", value: "asset-library" }, +] as const; const ContainerImg = (props: RecordConstructorToView) => { const imgRef = useRef(null); @@ -194,7 +200,11 @@ const ContainerImg = (props: RecordConstructorToView) => { } > ) => { }; const childrenMap = { + sourceMode: dropdownControl(ModeOptions, "standard"), src: withDefault(StringStateControl, "https://temp.im/350x400"), + iconScoutAsset: IconscoutControl(AssetType.ILLUSTRATION), onEvent: eventHandlerControl(EventOptions), style: styleControl(ImageStyle , 'style'), animationStyle: styleControl(AnimationStyle , 'animationStyle'), @@ -234,7 +246,14 @@ let ImageBasicComp = new UICompBuilder(childrenMap, (props) => { return ( <>
- {children.src.propertyView({ + { children.sourceMode.propertyView({ + label: "", + radioButton: true + })} + {children.sourceMode.getView() === 'standard' && children.src.propertyView({ + label: trans("image.src"), + })} + {children.sourceMode.getView() === 'asset-library' && children.iconScoutAsset.propertyView({ label: trans("image.src"), })}
diff --git a/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx index c3f93b6e1..4cb3881be 100644 --- a/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx @@ -2,6 +2,7 @@ import { hiddenPropertyView, showDataLoadingIndicatorsPropertyView } from "comps import { ArrayOrJSONObjectControl, NumberControl, + StringControl, } from "comps/controls/codeControl"; import { dropdownControl } from "comps/controls/dropdownControl"; import { BoolControl } from "comps/controls/boolControl"; @@ -9,8 +10,8 @@ import { styleControl } from "comps/controls/styleControl"; import { AnimationStyle, LottieStyle } from "comps/controls/styleControlConstants"; import { trans } from "i18n"; import { Section, sectionNames } from "lowcoder-design"; -import { useContext, lazy, useEffect } from "react"; -import { UICompBuilder, withDefault } from "../../generators"; +import { useContext, lazy, useEffect, useState } from "react"; +import { stateComp, UICompBuilder, withDefault } from "../../generators"; import { NameConfig, NameConfigHidden, @@ -18,10 +19,22 @@ import { } from "../../generators/withExposing"; import { defaultLottie } from "./jsonConstants"; import { EditorContext } from "comps/editorState"; +import { AssetType, IconscoutControl } from "@lowcoder-ee/comps/controls/iconscoutControl"; +import { DotLottie } from "@lottiefiles/dotlottie-react"; +import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; +import { useResizeDetector } from "react-resize-detector"; +import { eventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl"; +import { withMethodExposing } from "@lowcoder-ee/comps/generators/withMethodExposing"; +import { changeChildAction } from "lowcoder-core"; -const Player = lazy( - () => import('@lottiefiles/react-lottie-player') - .then(module => ({default: module.Player})) +// const Player = lazy( +// () => import('@lottiefiles/react-lottie-player') +// .then(module => ({default: module.Player})) +// ); + +const DotLottiePlayer = lazy( + () => import('@lottiefiles/dotlottie-react') + .then(module => ({default: module.DotLottieReact})) ); /** @@ -34,7 +47,11 @@ const animationStartOptions = [ }, { label: trans("jsonLottie.onHover"), - value: "on hover", + value: "hover", + }, + { + label: trans("jsonLottie.onTrigger"), + value: "trigger", }, ] as const; @@ -84,12 +101,48 @@ const speedOptions = [ }, ] as const; +const alignOptions = [ + { label: "None", value: "none" }, + { label: "Fill", value: "fill" }, + { label: "Cover", value: "cover" }, + { label: "Contain", value: "contain" }, + { label: "Fit Width", value: "fit-width" }, + { label: "Fit Height", value: "fit-height" }, +] as const; + +const fitOptions = [ + { label: "Top Left", value: "0,0" }, + { label: "Top Center", value: "0.5,0" }, + { label: "Top Right", value: "1,0" }, + { label: "Center Left", value: "0,0.5" }, + { label: "Center", value: "0.5,0.5" }, + { label: "Center Right", value: "1,0.5" }, + { label: "Bottom Left", value: "0,1" }, + { label: "Bottom Center", value: "0.5,1" }, + { label: "Bottom Right", value: "1,1" }, +] as const; + +const ModeOptions = [ + { label: "Lottie JSON", value: "standard" }, + { label: "Asset Library", value: "asset-library" } +] as const; + +const EventOptions = [ + { label: trans("jsonLottie.load"), value: "load", description: trans("jsonLottie.load") }, + { label: trans("jsonLottie.play"), value: "play", description: trans("jsonLottie.play") }, + { label: trans("jsonLottie.pause"), value: "pause", description: trans("jsonLottie.pause") }, + { label: trans("jsonLottie.stop"), value: "stop", description: trans("jsonLottie.stop") }, + { label: trans("jsonLottie.complete"), value: "complete", description: trans("jsonLottie.complete") }, +] as const;; + let JsonLottieTmpComp = (function () { const childrenMap = { + sourceMode: dropdownControl(ModeOptions, "standard"), value: withDefault( ArrayOrJSONObjectControl, JSON.stringify(defaultLottie, null, 2) ), + iconScoutAsset: IconscoutControl(AssetType.LOTTIE), speed: dropdownControl(speedOptions, "1"), width: withDefault(NumberControl, 100), height: withDefault(NumberControl, 100), @@ -98,11 +151,83 @@ let JsonLottieTmpComp = (function () { animationStart: dropdownControl(animationStartOptions, "auto"), loop: dropdownControl(loopOptions, "single"), keepLastFrame: BoolControl.DEFAULT_TRUE, + autoHeight: withDefault(AutoHeightControl, "auto"), + aspectRatio: withDefault(StringControl, "1/1"), + fit: dropdownControl(alignOptions, "contain"), + align: dropdownControl(fitOptions, "0.5,0.5"), + onEvent: eventHandlerControl(EventOptions), + dotLottieRef: stateComp(null), }; - return new UICompBuilder(childrenMap, (props) => { + return new UICompBuilder(childrenMap, (props, dispatch) => { + const [dotLottie, setDotLottie] = useState(null); + + const setLayoutAndResize = () => { + const align = props.align.split(','); + dotLottie?.setLayout({fit: props.fit, align: [Number(align[0]), Number(align[1])]}) + dotLottie?.resize(); + } + + const { ref: wrapperRef } = useResizeDetector({ + onResize: () => { + if (dotLottie) { + setLayoutAndResize(); + } + } + }); + + useEffect(() => { + const onComplete = () => { + props.keepLastFrame && dotLottie?.setFrame(100); + props.onEvent('complete'); + } + + const onLoad = () => { + setLayoutAndResize(); + props.onEvent('load'); + } + + const onPlay = () => { + props.onEvent('play'); + } + + const onPause = () => { + props.onEvent('pause'); + } + + const onStop = () => { + props.onEvent('stop'); + } + + if (dotLottie) { + dotLottie.addEventListener('complete', onComplete); + dotLottie.addEventListener('load', onLoad); + dotLottie.addEventListener('play', onPlay); + dotLottie.addEventListener('pause', onPause); + dotLottie.addEventListener('stop', onStop); + } + + return () => { + if (dotLottie) { + dotLottie.removeEventListener('complete', onComplete); + dotLottie.removeEventListener('load', onLoad); + dotLottie.removeEventListener('play', onPlay); + dotLottie.removeEventListener('pause', onPause); + dotLottie.removeEventListener('stop', onStop); + } + }; + }, [dotLottie, props.keepLastFrame]); + + useEffect(() => { + if (dotLottie) { + setLayoutAndResize(); + } + }, [dotLottie, props.fit, props.align, props.autoHeight]); + return (
- { + setDotLottie(lottieRef); + dispatch( + changeChildAction("dotLottieRef", lottieRef as any, false) + ) + }} + autoplay={props.animationStart === "auto"} loop={props.loop === "single" ? false : true} speed={Number(props.speed)} - src={props.value} + data={props.sourceMode === 'standard' ? props.value as Record : undefined} + src={props.sourceMode === 'asset-library' ? props.iconScoutAsset?.value : undefined} style={{ - height: "100%", - width: "100%", - maxWidth: "100%", - maxHeight: "100%", + aspectRatio: props.aspectRatio, }} + onMouseEnter={() => props.animationStart === "hover" && dotLottie?.play()} + onMouseLeave={() => props.animationStart === "hover" && dotLottie?.pause()} />
@@ -145,23 +274,42 @@ let JsonLottieTmpComp = (function () { return ( <>
- {children.value.propertyView({ + { children.sourceMode.propertyView({ + label: "", + radioButton: true + })} + {children.sourceMode.getView() === 'standard' && children.value.propertyView({ label: trans("jsonLottie.lottieJson"), })} + {children.sourceMode.getView() === 'asset-library' && children.iconScoutAsset.propertyView({ + label: "Lottie Source", + })}
{(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && ( <>
+ {children.onEvent.getPropertyView()} {children.speed.propertyView({ label: trans("jsonLottie.speed")})} {children.loop.propertyView({ label: trans("jsonLottie.loop")})} {children.animationStart.propertyView({ label: trans("jsonLottie.animationStart")})} - {children.keepLastFrame.propertyView({ label: trans("jsonLottie.keepLastFrame")})} {hiddenPropertyView(children)} + {children.keepLastFrame.propertyView({ label: trans("jsonLottie.keepLastFrame")})} {showDataLoadingIndicatorsPropertyView(children)}
)} + {["layout", "both"].includes(useContext(EditorContext).editorModeStatus) && ( +
+ {children.autoHeight.getPropertyView()} + {children.aspectRatio.propertyView({ + label: trans("style.aspectRatio"), + })} + {children.align.propertyView({ label: trans("jsonLottie.align")})} + {children.fit.propertyView({ label: trans("jsonLottie.fit")})} +
+ )} + {(useContext(EditorContext).editorModeStatus === "layout" || useContext(EditorContext).editorModeStatus === "both") && ( <>
@@ -179,9 +327,43 @@ let JsonLottieTmpComp = (function () { })(); JsonLottieTmpComp = class extends JsonLottieTmpComp { override autoHeight(): boolean { - return false; + return this.children.autoHeight.getView(); } }; + +JsonLottieTmpComp = withMethodExposing(JsonLottieTmpComp, [ + { + method: { + name: "play", + description: trans("jsonLottie.play"), + params: [], + }, + execute: (comp) => { + (comp.children.dotLottieRef.value as unknown as DotLottie)?.play(); + }, + }, + { + method: { + name: "pause", + description: trans("jsonLottie.pause"), + params: [], + }, + execute: (comp) => { + (comp.children.dotLottieRef.value as unknown as DotLottie)?.pause(); + }, + }, + { + method: { + name: "stop", + description: trans("jsonLottie.stop"), + params: [], + }, + execute: (comp) => { + (comp.children.dotLottieRef.value as unknown as DotLottie)?.stop(); + }, + }, +]); + export const JsonLottieComp = withExposingConfigs(JsonLottieTmpComp, [ new NameConfig("value", trans("jsonLottie.valueDesc")), NameConfigHidden, diff --git a/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx b/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx index 28e318618..0e31deb50 100644 --- a/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx +++ b/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx @@ -39,6 +39,7 @@ import { useEffect, useRef, useState } from "react"; import ReactResizeDetector from "react-resize-detector"; import { useContext } from "react"; +import { AssetType, IconscoutControl } from "@lowcoder-ee/comps/controls/iconscoutControl"; const Container = styled.div<{ $style: any }>` height: 100%; @@ -74,9 +75,15 @@ const IconWrapper = styled.div<{ $style: any }>` ${(props) => props.$style && getStyleIcon(props.$style)} `; +const IconScoutWrapper = styled.div<{ $style: any }>` + display: flex; + + ${(props) => props.$style && getStyleIcon(props.$style)} +`; + function getStyleIcon(style: any) { return css` - svg { + svg, img { width: ${style.size} !important; height: ${style.size} !important; } @@ -163,6 +170,11 @@ const typeOptions = [ }, ] as const; +const ModeOptions = [ + { label: "Standard", value: "standard" }, + { label: "Asset Library", value: "asset-library" }, +] as const; + function isDefault(type?: string) { return !type; } @@ -183,7 +195,9 @@ const childrenMap = { disabled: BoolCodeControl, loading: BoolCodeControl, form: SelectFormControl, + sourceMode: dropdownControl(ModeOptions, "standard"), prefixIcon: IconControl, + iconScoutAsset: IconscoutControl(AssetType.ICON), style: ButtonStyleControl, viewRef: RefControl, restrictPaddingOnRotation:withDefault(StringControl, 'controlButton') @@ -270,14 +284,20 @@ let ButtonTmpComp = (function () { : submitForm(editorState, props.form) } > - {props.prefixIcon && ( + {props.sourceMode === 'standard' && props.prefixIcon && ( {props.prefixIcon} )} - + {props.sourceMode === 'asset-library' && props.iconScoutAsset && ( + + + + )} @@ -291,7 +311,14 @@ let ButtonTmpComp = (function () { .setPropertyViewFn((children) => ( <>
- {children.prefixIcon.propertyView({ + { children.sourceMode.propertyView({ + label: "", + radioButton: true + })} + {children.sourceMode.getView() === 'standard' && children.prefixIcon.propertyView({ + label: trans("button.icon"), + })} + {children.sourceMode.getView() === 'asset-library' &&children.iconScoutAsset.propertyView({ label: trans("button.icon"), })}
@@ -314,13 +341,13 @@ let ButtonTmpComp = (function () { {children.iconSize.propertyView({ label: trans("button.iconSize"), })} -
-
- {children.style.getPropertyView()} {children.aspectRatio.propertyView({ label: trans("style.aspectRatio"), })}
+
+ {children.style.getPropertyView()} +
)} diff --git a/client/packages/lowcoder/src/comps/controls/iconscoutControl.tsx b/client/packages/lowcoder/src/comps/controls/iconscoutControl.tsx new file mode 100644 index 000000000..cefb85b3a --- /dev/null +++ b/client/packages/lowcoder/src/comps/controls/iconscoutControl.tsx @@ -0,0 +1,498 @@ +import { trans } from "i18n"; +import { + SimpleComp, +} from "lowcoder-core"; +import { + BlockGrayLabel, + ControlPropertyViewWrapper, + CustomModal, + DeleteInputIcon, + TacoButton, + TacoInput, + useIcon, + wrapperToControlItem, +} from "lowcoder-design"; +import { ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import styled from "styled-components"; +import Popover from "antd/es/popover"; +import { CloseIcon, SearchIcon } from "icons"; +import Draggable from "react-draggable"; +import IconScoutApi from "@lowcoder-ee/api/iconScoutApi"; +import { searchAssets, getAssetLinks, SearchParams } from "@lowcoder-ee/api/iconFlowApi"; +import List, { ListRowProps } from "react-virtualized/dist/es/List"; +import { debounce } from "lodash"; +import Spin from "antd/es/spin"; +import { ControlParams } from "./controlParams"; +import { getBase64 } from "@lowcoder-ee/util/fileUtils"; +import Flex from "antd/es/flex"; +import Typography from "antd/es/typography"; +import LoadingOutlined from "@ant-design/icons/LoadingOutlined"; +import Badge from "antd/es/badge"; +import { CrownFilled } from "@ant-design/icons"; +import { SUBSCRIPTION_SETTING } from "@lowcoder-ee/constants/routesURL"; + +const ButtonWrapper = styled.div` + width: 100%; + display: flex; + align-items: center; +`; +const ButtonIconWrapper = styled.div` + display: flex; + width: 18px; +`; + +const StyledDeleteInputIcon = styled(DeleteInputIcon)` + margin-left: auto; + cursor: pointer; + + &:hover circle { + fill: #8b8fa3; + } +`; + +const StyledImage = styled.img` + height: 100%; + width: 100%; + color: currentColor; +`; + +const Wrapper = styled.div` + > div:nth-of-type(1) { + margin-bottom: 4px; + } +`; +const PopupContainer = styled.div` + display: flex; + flex-direction: column; + width: 580px; + min-height: 480px; + background: #ffffff; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); + border-radius: 8px; + box-sizing: border-box; +`; + +const TitleDiv = styled.div` + height: 48px; + display: flex; + align-items: center; + padding: 0 16px; + justify-content: space-between; + user-select: none; +`; +const TitleText = styled.span` + font-size: 16px; + color: #222222; + line-height: 16px; +`; +const StyledCloseIcon = styled(CloseIcon)` + width: 16px; + height: 16px; + cursor: pointer; + color: #8b8fa3; + + &:hover g line { + stroke: #222222; + } +`; + +const SearchDiv = styled.div` + position: relative; + margin: 0px 16px; + padding-bottom: 8px; + display: flex; + justify-content: space-between; +`; +const StyledSearchIcon = styled(SearchIcon)` + position: absolute; + top: 6px; + left: 12px; +`; +const IconListWrapper = styled.div` + padding-left: 10px; + padding-right: 4px; +`; +const IconList = styled(List)` + scrollbar-gutter: stable; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background-clip: content-box; + border-radius: 9999px; + background-color: rgba(139, 143, 163, 0.2); + } + + &::-webkit-scrollbar-thumb:hover { + background-color: rgba(139, 143, 163, 0.36); + } +`; + +const IconRow = styled.div` + padding: 6px; + display: flex; + align-items: flex-start; /* Align items to the start to allow different heights */ + justify-content: space-between; + + &:last-child { + gap: 8px; + justify-content: flex-start; + } + + .ant-badge { + height: 100%; + } +`; + +const IconItemContainer = styled.div` + width: 60px; + height: 60px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + cursor: pointer; + font-size: 28px; + border-radius: 4px; + background: #fafafa; + + &:hover { + box-shadow: 0 8px 24px #1a29470a,0 2px 8px #1a294714; + } + + &:focus { + border: 1px solid #315efb; + box-shadow: 0 0 0 2px #d6e4ff; + } +`; + +const IconWrapper = styled.div<{$isPremium?: boolean}>` + height: 100%; + display: flex; + align-items: center; + justify-content: center; + ${props => props.$isPremium && 'opacity: 0.25' }; +`; + +const StyledPreviewIcon = styled.img` + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; +`; + +const StyledPreviewLotte = styled.video` + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; +` + +export enum AssetType { + ICON = "icon", + ILLUSTRATION = "illustration", + // '3D' = "3d", + LOTTIE = "lottie", +} + +export type IconScoutAsset = { + uuid: string; + value: string; + preview: string; +} + +const IconScoutSearchParams: SearchParams = { + query: '', + asset: 'icon', + per_page: 25, + page: 1, + sort: 'relevant', +}; + +const columnNum = 8; + +export const IconPicker = (props: { + assetType: string; + uuid: string; + value: string; + preview: string; + onChange: (key: string, value: string, preview: string) => void; + label?: ReactNode; + IconType?: "OnlyAntd" | "All" | "default" | undefined; +}) => { + const [ visible, setVisible ] = useState(false) + const [ loading, setLoading ] = useState(false) + const [ downloading, setDownloading ] = useState(false) + const [ searchText, setSearchText ] = useState('') + const [ searchResults, setSearchResults ] = useState>([]); + + const onChangeRef = useRef(props.onChange); + onChangeRef.current = props.onChange; + + const onChangeIcon = useCallback( + (key: string, value: string, url: string) => { + onChangeRef.current(key, value, url); + setVisible(false); + }, [] + ); + + const fetchResults = async (query: string) => { + setLoading(true); + const freeResult = await searchAssets({ + ...IconScoutSearchParams, + asset: props.assetType, + price: 'free', + query, + }); + const premiumResult = await searchAssets({ + ...IconScoutSearchParams, + asset: props.assetType, + price: 'premium', + query, + }); + setLoading(false); + + console.log("freeResult", freeResult, "premiumResult", premiumResult) + + setSearchResults([...freeResult.data, ...premiumResult.data]); + }; + + const downloadAsset = async ( + uuid: string, + downloadUrl: string, + callback: (assetUrl: string) => void, + ) => { + try { + if (uuid && downloadUrl) { + const json = await IconScoutApi.downloadAsset(downloadUrl); + getBase64(json, (url: string) => { + callback(url); + }); + } + } catch(error) { + console.error(error); + setDownloading(false); + } + } + + const fetchDownloadUrl = async (uuid: string, preview: string) => { + try { + setDownloading(true); + const result = await getAssetLinks(uuid, { + format: props.assetType === AssetType.LOTTIE ? 'lottie' : 'svg', + }); + + downloadAsset(uuid, result.download_url, (assetUrl: string) => { + setDownloading(false); + onChangeIcon(uuid, assetUrl, preview); + }); + } catch (error) { + console.error(error); + setDownloading(false); + } + } + + const handleChange = (e: { target: { value: any; }; }) => { + const query = e.target.value; + setSearchText(query); // Update search text immediately + + if (query.length > 2) { + debouncedFetchResults(query); // Trigger search only for >2 characters + } else { + setSearchResults([]); // Clear results if input is too short + } + }; + + const debouncedFetchResults = useMemo(() => debounce(fetchResults, 700), []); + + const rowRenderer = useCallback( + (p: ListRowProps) => ( + + {searchResults + .slice(p.index * columnNum, (p.index + 1) * columnNum) + .map((icon) => ( + { + // check if premium content then show subscription popup + // TODO: if user has subscription then skip this if block + if (icon.price !== 0) { + CustomModal.confirm({ + title: trans("iconScout.buySubscriptionTitle"), + content: trans("iconScout.buySubscriptionContent"), + onConfirm: () =>{ + window.open(SUBSCRIPTION_SETTING, "_blank"); + }, + confirmBtnType: "primary", + okText: trans("iconScout.buySubscriptionButton"), + }) + return; + } + + fetchDownloadUrl( + icon.uuid, + props.assetType === AssetType.ICON ? icon.urls.png_64 : icon.urls.thumb, + ); + }} + > + : undefined} + size='small' + > + + {props.assetType === AssetType.ICON && ( + + )} + {props.assetType === AssetType.ILLUSTRATION && ( + + )} + {props.assetType === AssetType.LOTTIE && ( + + )} + + + + ))} + + ),[searchResults] + ); + + const popupTitle = useMemo(() => { + if (props.assetType === AssetType.ILLUSTRATION) return trans("iconScout.searchImage"); + if (props.assetType === AssetType.LOTTIE) return trans("iconScout.searchAnimation"); + return trans("iconScout.searchIcon"); + }, [props.assetType]); + + return ( + + + + {popupTitle} + setVisible(false)} /> + + + + + + {loading && ( + + } /> + + )} + } > + {!loading && Boolean(searchText) && !Boolean(searchResults?.length) && ( + + + {trans("iconScout.noResults")} + + + )} + {!loading && Boolean(searchText) && Boolean(searchResults?.length) && ( + + + + )} + + + + } + > + + {props.preview ? ( + + + {props.assetType === AssetType.LOTTIE && ( + + { + props.onChange("", "", ""); + e.stopPropagation(); + }} + /> + + ) : ( + + )} + + + ); +}; + +export function IconControlView(props: { value: string, uuid: string }) { + const { value } = props; + const icon = useIcon(value); + + if (icon) { + return icon.getView(); + } + return ; +} + +export function IconscoutControl( + assetType: string = AssetType.ICON, +) { + return class IconscoutControl extends SimpleComp { + readonly IGNORABLE_DEFAULT_VALUE = false; + protected getDefaultValue(): IconScoutAsset { + return { + uuid: '', + value: '', + preview: '', + }; + } + + override getPropertyView(): ReactNode { + throw new Error("Method not implemented."); + } + + propertyView(params: ControlParams & { type?: "switch" | "checkbox" }) { + return wrapperToControlItem( + + { + this.dispatchChangeValueAction({uuid, value, preview}) + }} + label={params.label} + IconType={params.IconType} + /> + + ); + } + } +} diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 48d4cbdc4..6157736bf 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -3761,9 +3761,17 @@ export const en = { "loop": "Loop", "auto": "Auto", "onHover": "On Hover", + "onTrigger": "On Trigger", "singlePlay": "Single Play", "endlessLoop": "Endless Loop", - "keepLastFrame": "Keep Last Frame displayed" + "keepLastFrame": "Keep Last Frame displayed", + "fit": "Fit", + "align": "Align", + "load": "On Load", + "play": "On Play", + "pause": "On Pause", + "stop": "On Stop", + "complete": "On Complete", }, "timeLine": { "titleColor": "Title Color", @@ -4068,6 +4076,15 @@ export const en = { discord: "https://discord.com/invite/qMG9uTmAx2", }, + iconScout: { + "searchImage": "Search Image", + "searchAnimation": "Search Animation", + "searchIcon": "Search Icon", + "noResults": "No results found.", + "buySubscriptionTitle": "Unlock Premium Assets", + "buySubscriptionContent": "This asset is exclusive to Media Package Subscribers. Subscribe to Media Package and download high-quality assets without limits!", + "buySubscriptionButton": "Subscribe Now", + } }; // const jsonString = JSON.stringify(en, null, 2); diff --git a/client/yarn.lock b/client/yarn.lock index cbdfec3bc..ddcb2640a 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3113,6 +3113,24 @@ __metadata: languageName: node linkType: hard +"@lottiefiles/dotlottie-react@npm:^0.13.0": + version: 0.13.0 + resolution: "@lottiefiles/dotlottie-react@npm:0.13.0" + dependencies: + "@lottiefiles/dotlottie-web": 0.40.1 + peerDependencies: + react: ^17 || ^18 || ^19 + checksum: bafe6ded727aab991ff03f6ff5a2fd1a41b1f429b36175f34140017fc684e0a8ef7f7b713d189bd49948c4b728fe1d05c7d8c20a0bea0d8c1ae1ed87614fe843 + languageName: node + linkType: hard + +"@lottiefiles/dotlottie-web@npm:0.40.1": + version: 0.40.1 + resolution: "@lottiefiles/dotlottie-web@npm:0.40.1" + checksum: a79e60c33845311cb055ea661abb2f4211063e149788aea724afbed05a09ae569d50b4c0e5825d13eb5fc62a33c3dc74f2f3900fdb1e99f8594feddc72d2cc27 + languageName: node + linkType: hard + "@lottiefiles/react-lottie-player@npm:^3.5.3": version: 3.5.3 resolution: "@lottiefiles/react-lottie-player@npm:3.5.3" @@ -14212,6 +14230,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: "@fortawesome/free-regular-svg-icons": ^6.5.1 "@fortawesome/free-solid-svg-icons": ^6.5.1 "@fortawesome/react-fontawesome": latest + "@lottiefiles/dotlottie-react": ^0.13.0 "@manaflair/redux-batch": ^1.0.0 "@rjsf/antd": ^5.21.2 "@rjsf/core": ^5.21.2 diff --git a/server/node-service/yarn.lock b/server/node-service/yarn.lock index f61d4ea81..00133160c 100644 --- a/server/node-service/yarn.lock +++ b/server/node-service/yarn.lock @@ -4946,8 +4946,8 @@ __metadata: linkType: hard "ali-oss@npm:^6.20.0": - version: 6.21.0 - resolution: "ali-oss@npm:6.21.0" + version: 6.22.0 + resolution: "ali-oss@npm:6.22.0" dependencies: address: ^1.2.2 agentkeepalive: ^3.4.1 @@ -4974,7 +4974,7 @@ __metadata: urllib: ^2.44.0 utility: ^1.18.0 xml2js: ^0.6.2 - checksum: 26424e96c4a927e08d6aa9480a0f9db6da00b0188a7a45672f3244344f5cf6ff81aee72a8e46dcd240c98f168d09e2853ac84ae9e5764bc673b4d959a11a5e51 + checksum: 7120edb25dc92311b25a9dd782b7482a4a6d835a5cf212a6f57f6d3df153f9e09a6317a8b2bedad04b7e343a8e1be057bc032779ec06ef6df353f2a02cdf235c languageName: node linkType: hard @@ -5890,25 +5890,25 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0": - version: 7.0.6 - resolution: "cross-spawn@npm:7.0.6" +"cross-spawn@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" dependencies: path-key: ^3.1.0 shebang-command: ^2.0.0 which: ^2.0.1 - checksum: 8d306efacaf6f3f60e0224c287664093fa9185680b2d195852ba9a863f85d02dcc737094c6e512175f8ee0161f9b87c73c6826034c2422e39de7d6569cf4503b + checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52 languageName: node linkType: hard -"cross-spawn@npm:^7.0.3": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" +"cross-spawn@npm:^7.0.6": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" dependencies: path-key: ^3.1.0 shebang-command: ^2.0.0 which: ^2.0.1 - checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52 + checksum: 8d306efacaf6f3f60e0224c287664093fa9185680b2d195852ba9a863f85d02dcc737094c6e512175f8ee0161f9b87c73c6826034c2422e39de7d6569cf4503b languageName: node linkType: hard @@ -6735,12 +6735,12 @@ __metadata: linkType: hard "foreground-child@npm:^3.1.0": - version: 3.3.0 - resolution: "foreground-child@npm:3.3.0" + version: 3.3.1 + resolution: "foreground-child@npm:3.3.1" dependencies: - cross-spawn: ^7.0.0 + cross-spawn: ^7.0.6 signal-exit: ^4.0.1 - checksum: 1989698488f725b05b26bc9afc8a08f08ec41807cd7b92ad85d96004ddf8243fd3e79486b8348c64a3011ae5cc2c9f0936af989e1f28339805d8bc178a75b451 + checksum: b2c1a6fc0bf0233d645d9fefdfa999abf37db1b33e5dab172b3cbfb0662b88bfbd2c9e7ab853533d199050ec6b65c03fcf078fc212d26e4990220e98c6930eef languageName: node linkType: hard