From 1a2e96474e8bb60f3bb466aa45dbb884e6ace153 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 11:02:54 +0200 Subject: [PATCH 01/13] Update docker-images.yml Adding Enterprise Edition Docker Image --- .github/workflows/docker-images.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index d075f1fdc..97b1c85aa 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -146,6 +146,24 @@ jobs: push: true tags: ${{ env.FRONTEND_IMAGE_NAMES }} + - name: Build and push the enterprise edition frontend image + if: ${{ env.BUILD_FRONTEND == 'true' }} + uses: docker/build-push-action@v6 + env: + NODE_ENV: production + with: + file: ./deploy/docker/Dockerfile + target: lowcoder-ce-frontend + build-args: | + REACT_APP_ENV=production + REACT_APP_EDITION=enterprise + REACT_APP_COMMIT_ID="dev #${{ env.SHORT_SHA }}" + platforms: | + linux/amd64 + linux/arm64 + push: true + tags: ${{ env.FRONTEND_IMAGE_NAMES }} + - name: Build and push the node service image if: ${{ env.BUILD_NODESERVICE == 'true' }} uses: docker/build-push-action@v6 From 3ca7794a3b4ad5896928ed557b42da9a6e402830 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 15:14:40 +0500 Subject: [PATCH 02/13] added ee build command --- client/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/package.json b/client/package.json index 12f93a4aa..08ebeac4c 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "start:ee": "REACT_APP_EDITION=enterprise yarn workspace lowcoder start", "translate": "node --loader ts-node/esm ./scripts/translate.js", "build": "yarn node ./scripts/build.js", + "build:ee": "REACT_APP_EDITION=enterprise yarn node ./scripts/build.js", "test": "jest && yarn workspace lowcoder-comps test", "prepare": "yarn workspace lowcoder prepare", "build:core": "yarn workspace lowcoder-core build", From c6d018c7a62de99deea19bdbda475ccee1a343d9 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 15:27:52 +0500 Subject: [PATCH 03/13] updated Dockerfile to add separate lowcoder-ee-frontend image --- deploy/docker/Dockerfile | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 5ecbbd579..2c26de959 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -185,6 +185,84 @@ EXPOSE 3443 ############################################################################# +## +## Build lowcoder client (Enterprise) application +## +FROM node:20.2-slim AS build-client-ee + +# curl is required for yarn build to succeed, because it calls it while building client +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + +# Build client +COPY ./client /lowcoder-client-ee +WORKDIR /lowcoder-client-ee +RUN yarn --immutable + +ARG REACT_APP_COMMIT_ID=test +ARG REACT_APP_ENV=production +ARG REACT_APP_EDITION=community +ARG REACT_APP_DISABLE_JS_SANDBOX=true +RUN yarn build:ee + +# Build lowcoder-comps +WORKDIR /lowcoder-client-ee/packages/lowcoder-comps +RUN yarn install +RUN yarn build +RUN tar -zxf lowcoder-comps-*.tgz && mv package lowcoder-comps + +# Build lowcoder-sdk +WORKDIR /lowcoder-client-ee/packages/lowcoder-sdk +RUN yarn install +RUN yarn build + +WORKDIR /lowcoder-client-ee/packages/lowcoder-sdk-webpack-bundle +RUN yarn install +RUN yarn build + +## +## Intermediary Lowcoder client (Enterprise) image +## +## To create a separate image out of it, build it with: +## DOCKER_BUILDKIT=1 docker build -f deploy/docker/Dockerfile -t lowcoderorg/lowcoder-ee-frontend --target lowcoder-ee-frontend . +## +FROM nginx:1.27.1 AS lowcoder-ee-frontend +LABEL maintainer="lowcoder" + +# Change default nginx user into lowcoder user and remove default nginx config +RUN usermod --login lowcoder --uid 9001 nginx \ + && groupmod --new-name lowcoder --gid 9001 nginx \ + && rm -f /etc/nginx/nginx.conf \ + && mkdir -p /lowcoder/assets + +# Copy lowcoder client +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder/build/ /lowcoder/client +# Copy lowcoder components +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder-comps/lowcoder-comps /lowcoder/client-comps +# Copy lowcoder SDK +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder-sdk /lowcoder/client-sdk +# Copy lowcoder SDK webpack bundle +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder-sdk-webpack-bundle/dist /lowcoder/client-embed + + +# Copy additional nginx init scripts +COPY deploy/docker/frontend/00-change-nginx-user.sh /docker-entrypoint.d/00-change-nginx-user.sh +COPY deploy/docker/frontend/01-update-nginx-conf.sh /docker-entrypoint.d/01-update-nginx-conf.sh + +RUN chmod +x /docker-entrypoint.d/00-change-nginx-user.sh && \ + chmod +x /docker-entrypoint.d/01-update-nginx-conf.sh + +COPY deploy/docker/frontend/server.conf /etc/nginx/server.conf +COPY deploy/docker/frontend/nginx-http.conf /etc/nginx/nginx-http.conf +COPY deploy/docker/frontend/nginx-https.conf /etc/nginx/nginx-https.conf +COPY deploy/docker/frontend/ssl-certificate.conf /etc/nginx/ssl-certificate.conf +COPY deploy/docker/frontend/ssl-params.conf /etc/nginx/ssl-params.conf + + +EXPOSE 3000 +EXPOSE 3444 + +############################################################################# + ## ## Build Lowcoder all-in-one image ## From b8211088d01141bf5a141e7427ca3fa9c3fb75f4 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 15:35:34 +0500 Subject: [PATCH 04/13] updated github workflow ee image --- .github/workflows/docker-images.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 97b1c85aa..e26af7636 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -80,18 +80,21 @@ jobs: # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} FRONTEND_IMAGE_NAMES=lowcoderorg/lowcoder-ce-frontend:${IMAGE_TAG} + FRONTEND_EE_IMAGE_NAMES=lowcoderorg/lowcoder-ee-frontend:${IMAGE_TAG} APISERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-api-service:${IMAGE_TAG} NODESERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-node-service:${IMAGE_TAG} if [[ "${IS_LATEST}" == "true" ]]; then ALLINONE_IMAGE_NAMES="lowcoderorg/lowcoder-ce:latest,${ALLINONE_IMAGE_NAMES}" FRONTEND_IMAGE_NAMES="lowcoderorg/lowcoder-ce-frontend:latest,${FRONTEND_IMAGE_NAMES}" + FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-ee-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" APISERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-api-service:latest,${APISERVICE_IMAGE_NAMES}" NODESERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-node-service:latest,${NODESERVICE_IMAGE_NAMES}" fi; echo "ALLINONE_IMAGE_NAMES=${ALLINONE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "FRONTEND_IMAGE_NAMES=${FRONTEND_IMAGE_NAMES}" >> "${GITHUB_ENV}" + echo "FRONTEND_EE_IMAGE_NAMES=${FRONTEND_EE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "APISERVICE_IMAGE_NAMES=${APISERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "NODESERVICE_IMAGE_NAMES=${NODESERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" @@ -153,7 +156,7 @@ jobs: NODE_ENV: production with: file: ./deploy/docker/Dockerfile - target: lowcoder-ce-frontend + target: lowcoder-ee-frontend build-args: | REACT_APP_ENV=production REACT_APP_EDITION=enterprise @@ -162,7 +165,7 @@ jobs: linux/amd64 linux/arm64 push: true - tags: ${{ env.FRONTEND_IMAGE_NAMES }} + tags: ${{ env.FRONTEND_EE_IMAGE_NAMES }} - name: Build and push the node service image if: ${{ env.BUILD_NODESERVICE == 'true' }} From 511f79ff13e101c374a7187147a6c863468f099b Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 17:34:21 +0500 Subject: [PATCH 05/13] update env variable --- deploy/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 2c26de959..611bad508 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -200,7 +200,7 @@ RUN yarn --immutable ARG REACT_APP_COMMIT_ID=test ARG REACT_APP_ENV=production -ARG REACT_APP_EDITION=community +ARG REACT_APP_EDITION=enterprise ARG REACT_APP_DISABLE_JS_SANDBOX=true RUN yarn build:ee From bdcae5a11fd864297bb4e38c2ffac8ad3437cdec Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 14:44:57 +0200 Subject: [PATCH 06/13] Update docker-images.yml --- .github/workflows/docker-images.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index e26af7636..9132a02c5 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -156,7 +156,7 @@ jobs: NODE_ENV: production with: file: ./deploy/docker/Dockerfile - target: lowcoder-ee-frontend + target: lowcoder-enterprise-frontend build-args: | REACT_APP_ENV=production REACT_APP_EDITION=enterprise From c16d1a4b366ceeed455a721beb03bd8e58abe7c5 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 15:15:21 +0200 Subject: [PATCH 07/13] Update docker-images.yml --- .github/workflows/docker-images.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 9132a02c5..439280b43 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -72,22 +72,22 @@ jobs: fi; # Control which images to build - echo "BUILD_ALLINONE=${{ inputs.build_allinone || true }}" >> "${GITHUB_ENV}" - echo "BUILD_FRONTEND=${{ inputs.build_frontend || true }}" >> "${GITHUB_ENV}" - echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || true }}" >> "${GITHUB_ENV}" - echo "BUILD_APISERVICE=${{ inputs.build_apiservice || true }}" >> "${GITHUB_ENV}" + echo "BUILD_ALLINONE=${{ inputs.build_allinone || false }}" >> "${GITHUB_ENV}" + echo "BUILD_FRONTEND=${{ inputs.build_frontend || false }}" >> "${GITHUB_ENV}" + echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || false }}" >> "${GITHUB_ENV}" + echo "BUILD_APISERVICE=${{ inputs.build_apiservice || false }}" >> "${GITHUB_ENV}" # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} FRONTEND_IMAGE_NAMES=lowcoderorg/lowcoder-ce-frontend:${IMAGE_TAG} - FRONTEND_EE_IMAGE_NAMES=lowcoderorg/lowcoder-ee-frontend:${IMAGE_TAG} + FRONTEND_EE_IMAGE_NAMES=lowcoderorg/lowcoder-enterprise-frontend:${IMAGE_TAG} APISERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-api-service:${IMAGE_TAG} NODESERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-node-service:${IMAGE_TAG} if [[ "${IS_LATEST}" == "true" ]]; then ALLINONE_IMAGE_NAMES="lowcoderorg/lowcoder-ce:latest,${ALLINONE_IMAGE_NAMES}" FRONTEND_IMAGE_NAMES="lowcoderorg/lowcoder-ce-frontend:latest,${FRONTEND_IMAGE_NAMES}" - FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-ee-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" + FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-enterprise-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" APISERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-api-service:latest,${APISERVICE_IMAGE_NAMES}" NODESERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-node-service:latest,${NODESERVICE_IMAGE_NAMES}" fi; From 42bb1104b8a797fe439ca8649f5418626d0c4f95 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 15:40:53 +0200 Subject: [PATCH 08/13] Update Dockerfile --- deploy/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 611bad508..e94ca2fa3 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -225,7 +225,7 @@ RUN yarn build ## To create a separate image out of it, build it with: ## DOCKER_BUILDKIT=1 docker build -f deploy/docker/Dockerfile -t lowcoderorg/lowcoder-ee-frontend --target lowcoder-ee-frontend . ## -FROM nginx:1.27.1 AS lowcoder-ee-frontend +FROM nginx:1.27.1 AS lowcoder-enterprise-frontend LABEL maintainer="lowcoder" # Change default nginx user into lowcoder user and remove default nginx config From 145819a19a4032aefb235d1f62730f57f4eb86a1 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Thu, 24 Jul 2025 16:40:24 +0200 Subject: [PATCH 09/13] Update docker-images.yml --- .github/workflows/docker-images.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 439280b43..be06cf1a4 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -72,10 +72,10 @@ jobs: fi; # Control which images to build - echo "BUILD_ALLINONE=${{ inputs.build_allinone || false }}" >> "${GITHUB_ENV}" - echo "BUILD_FRONTEND=${{ inputs.build_frontend || false }}" >> "${GITHUB_ENV}" - echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || false }}" >> "${GITHUB_ENV}" - echo "BUILD_APISERVICE=${{ inputs.build_apiservice || false }}" >> "${GITHUB_ENV}" + echo "BUILD_ALLINONE=${{ inputs.build_allinone || true }}" >> "${GITHUB_ENV}" + echo "BUILD_FRONTEND=${{ inputs.build_frontend || true }}" >> "${GITHUB_ENV}" + echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || true }}" >> "${GITHUB_ENV}" + echo "BUILD_APISERVICE=${{ inputs.build_apiservice || true }}" >> "${GITHUB_ENV}" # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} From 27b31d5c799a89d73007a7db00ca4f40938d72d1 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 28 Jul 2025 20:35:57 +0500 Subject: [PATCH 10/13] [Feat]: #1820 add search filter/sorting for columns gui query --- .../queries/sqlQuery/columnNameDropdown.tsx | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx b/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx index e33be20bd..d79c00a78 100644 --- a/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx +++ b/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx @@ -1,6 +1,6 @@ import { DispatchType } from "lowcoder-core"; import { ControlPlacement } from "../../controls/controlParams"; -import React, { useContext } from "react"; +import React, { useContext, useState, useEffect } from "react"; import { Dropdown, OptionsType } from "lowcoder-design"; import { isEmpty, values } from "lodash"; import { useSelector } from "react-redux"; @@ -8,6 +8,8 @@ import { getDataSourceStructures } from "../../../redux/selectors/datasourceSele import { changeValueAction } from "lowcoder-core"; import { QueryContext } from "../../../util/context/QueryContext"; +const COLUMN_SORT_KEY = "lowcoder_column_sort"; + export const ColumnNameDropdown = (props: { table: string; value: string; @@ -18,13 +20,27 @@ export const ColumnNameDropdown = (props: { }) => { const context = useContext(QueryContext); const datasourceId = context?.datasourceId ?? ""; - const columns: OptionsType = - values(useSelector(getDataSourceStructures)[datasourceId]) - ?.find((t) => t.name === props.table) - ?.columns.map((column) => ({ - label: column.name, - value: column.name, - })) ?? []; + + // Simple sort preference from localStorage + const [sortColumns, setSortColumns] = useState(() => { + return localStorage.getItem(COLUMN_SORT_KEY) === 'true'; + }); + + useEffect(() => { + localStorage.setItem(COLUMN_SORT_KEY, sortColumns.toString()); + }, [sortColumns]); + + const rawColumns = values(useSelector(getDataSourceStructures)[datasourceId]) + ?.find((t) => t.name === props.table) + ?.columns.map((column) => ({ + label: column.name, + value: column.name, + })) ?? []; + + const columns: OptionsType = sortColumns + ? [...rawColumns].sort((a, b) => a.label.localeCompare(b.label)) + : rawColumns; + return ( ( +
+ +
+ )} /> ); }; From 1fb070b37b2fc9427378c27adae5b1b3339a4603 Mon Sep 17 00:00:00 2001 From: Elier Herrera Date: Sat, 9 Aug 2025 00:05:31 -0400 Subject: [PATCH 11/13] feat(pwa-client): per-app PWA icon handling, maskable icons, editor display, and ApplicationV2 updates; add icon conversion utils --- client/package.json | 184 +++--- .../lowcoder/src/api/subscriptionApi.ts | 574 ++++++++++-------- client/packages/lowcoder/src/app.tsx | 270 ++++---- .../src/comps/comps/multiIconDisplay.tsx | 28 +- .../src/pages/ApplicationV2/index.tsx | 261 ++++---- .../lowcoder/src/pages/editor/editorView.tsx | 253 +++++--- .../lowcoder/src/util/iconConversionUtils.ts | 138 +++++ 7 files changed, 1002 insertions(+), 706 deletions(-) create mode 100644 client/packages/lowcoder/src/util/iconConversionUtils.ts diff --git a/client/package.json b/client/package.json index 32deab248..ab2ded1c7 100644 --- a/client/package.json +++ b/client/package.json @@ -1,94 +1,94 @@ { - "name": "lowcoder-frontend", - "version": "2.7.3", - "type": "module", - "private": true, - "workspaces": [ - "packages/*" - ], - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "scripts": { - "start": "yarn workspace lowcoder start", - "start-win": "LOWCODER_API_SERVICE_URL=http://localhost:3000 yarn start", - "start:ee": "REACT_APP_EDITION=enterprise yarn workspace lowcoder start", - "translate": "node --loader ts-node/esm ./scripts/translate.js", - "build": "yarn node ./scripts/build.js", - "build:ee": "REACT_APP_EDITION=enterprise yarn node ./scripts/build.js", - "test": "jest && yarn workspace lowcoder-comps test", - "prepare": "yarn workspace lowcoder prepare", - "build:core": "yarn workspace lowcoder-core build", - "test:core": "yarn workspace lowcoder-core test", - "lint": "eslint . --fix" - }, - "devDependencies": { - "@babel/preset-env": "^7.20.2", - "@babel/preset-typescript": "^7.18.6", - "@rollup/plugin-typescript": "^12.1.0", - "@testing-library/jest-dom": "^5.16.5", - "@types/file-saver": "^2.0.5", - "@types/jest": "^29.2.2", - "@types/mime": "^2.0.3", - "@types/qrcode.react": "^1.0.2", - "@types/react-grid-layout": "^1.3.0", - "@types/react-helmet": "^6.1.5", - "@types/react-resizable": "^3.0.5", - "@types/react-router-dom": "^5.3.2", - "@types/shelljs": "^0.8.11", - "@types/simplebar": "^5.3.3", - "@types/stylis": "^4.0.2", - "@types/tern": "0.23.4", - "@types/ua-parser-js": "^0.7.36", - "@welldone-software/why-did-you-render": "^6.2.3", - "add": "^2.0.6", - "babel-jest": "^29.3.0", - "babel-preset-react-app": "^10.0.1", - "babel-preset-vite": "^1.1.3", - "husky": "^8.0.1", - "jest": "^29.5.0", - "jest-canvas-mock": "^2.5.2", - "jest-environment-jsdom": "^29.5.0", - "lint-staged": "^13.0.1", - "lowcoder-cli": "workspace:^", - "mq-polyfill": "^1.1.8", - "prettier": "^3.1.0", - "rimraf": "^3.0.2", - "shelljs": "^0.8.5", - "svgo": "^3.0.0", - "ts-node": "^10.4.0", - "typescript": "^4.8.4", - "whatwg-fetch": "^3.6.2" - }, - "lint-staged": { - "**/*.{mjs,ts,tsx,json,md,html}": "prettier --write --ignore-unknown", - "**/*.svg": "svgo" - }, - "packageManager": "yarn@3.6.4", - "resolutions": { - "@types/react": "^18", - "moment": "2.29.2", - "canvas": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.2.1.tgz", - "react-virtualized@^9.22.3": "patch:react-virtualized@npm%3A9.22.3#./.yarn/patches/react-virtualized-npm-9.22.3-0fff3cbf64.patch", - "eslint-plugin-only-ascii@^0.0.0": "patch:eslint-plugin-only-ascii@npm%3A0.0.0#./.yarn/patches/eslint-plugin-only-ascii-npm-0.0.0-29e3417685.patch" - }, - "dependencies": { - "@lottiefiles/react-lottie-player": "^3.5.3", - "@remixicon/react": "^4.1.1", - "@supabase/supabase-js": "^2.45.4", - "@testing-library/react": "^14.1.2", - "@testing-library/user-event": "^14.5.1", - "@types/styled-components": "^5.1.34", - "antd-mobile": "^5.34.0", - "chalk": "4", - "flag-icons": "^7.2.1", - "number-precision": "^1.6.0", - "react-countup": "^6.5.3", - "react-github-btn": "^1.4.0", - "react-player": "^2.11.0", - "resize-observer-polyfill": "^1.5.1", - "rollup": "^4.22.5", - "simplebar": "^6.2.5", - "tui-image-editor": "^3.15.3" - } + "name": "lowcoder-frontend", + "version": "2.7.3", + "type": "module", + "private": true, + "workspaces": [ + "packages/*" + ], + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "scripts": { + "start": "yarn workspace lowcoder start", + "start-win": "LOWCODER_API_SERVICE_URL=http://localhost:8080 LOWCODER_NODE_SERVICE_URL=http://localhost:6060 yarn start", + "start:ee": "REACT_APP_EDITION=enterprise yarn workspace lowcoder start", + "translate": "node --loader ts-node/esm ./scripts/translate.js", + "build": "yarn node ./scripts/build.js", + "build:ee": "REACT_APP_EDITION=enterprise yarn node ./scripts/build.js", + "test": "jest && yarn workspace lowcoder-comps test", + "prepare": "yarn workspace lowcoder prepare", + "build:core": "yarn workspace lowcoder-core build", + "test:core": "yarn workspace lowcoder-core test", + "lint": "eslint . --fix" + }, + "devDependencies": { + "@babel/preset-env": "^7.20.2", + "@babel/preset-typescript": "^7.18.6", + "@rollup/plugin-typescript": "^12.1.0", + "@testing-library/jest-dom": "^5.16.5", + "@types/file-saver": "^2.0.5", + "@types/jest": "^29.2.2", + "@types/mime": "^2.0.3", + "@types/qrcode.react": "^1.0.2", + "@types/react-grid-layout": "^1.3.0", + "@types/react-helmet": "^6.1.5", + "@types/react-resizable": "^3.0.5", + "@types/react-router-dom": "^5.3.2", + "@types/shelljs": "^0.8.11", + "@types/simplebar": "^5.3.3", + "@types/stylis": "^4.0.2", + "@types/tern": "0.23.4", + "@types/ua-parser-js": "^0.7.36", + "@welldone-software/why-did-you-render": "^6.2.3", + "add": "^2.0.6", + "babel-jest": "^29.3.0", + "babel-preset-react-app": "^10.0.1", + "babel-preset-vite": "^1.1.3", + "husky": "^8.0.1", + "jest": "^29.5.0", + "jest-canvas-mock": "^2.5.2", + "jest-environment-jsdom": "^29.5.0", + "lint-staged": "^13.0.1", + "lowcoder-cli": "workspace:^", + "mq-polyfill": "^1.1.8", + "prettier": "^3.1.0", + "rimraf": "^3.0.2", + "shelljs": "^0.8.5", + "svgo": "^3.0.0", + "ts-node": "^10.4.0", + "typescript": "^4.8.4", + "whatwg-fetch": "^3.6.2" + }, + "lint-staged": { + "**/*.{mjs,ts,tsx,json,md,html}": "prettier --write --ignore-unknown", + "**/*.svg": "svgo" + }, + "packageManager": "yarn@3.6.4", + "resolutions": { + "@types/react": "^18", + "moment": "2.29.2", + "canvas": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.2.1.tgz", + "react-virtualized@^9.22.3": "patch:react-virtualized@npm%3A9.22.3#./.yarn/patches/react-virtualized-npm-9.22.3-0fff3cbf64.patch", + "eslint-plugin-only-ascii@^0.0.0": "patch:eslint-plugin-only-ascii@npm%3A0.0.0#./.yarn/patches/eslint-plugin-only-ascii-npm-0.0.0-29e3417685.patch" + }, + "dependencies": { + "@lottiefiles/react-lottie-player": "^3.5.3", + "@remixicon/react": "^4.1.1", + "@supabase/supabase-js": "^2.45.4", + "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.1", + "@types/styled-components": "^5.1.34", + "antd-mobile": "^5.34.0", + "chalk": "4", + "flag-icons": "^7.2.1", + "number-precision": "^1.6.0", + "react-countup": "^6.5.3", + "react-github-btn": "^1.4.0", + "react-player": "^2.11.0", + "resize-observer-polyfill": "^1.5.1", + "rollup": "^4.22.5", + "simplebar": "^6.2.5", + "tui-image-editor": "^3.15.3" + } } diff --git a/client/packages/lowcoder/src/api/subscriptionApi.ts b/client/packages/lowcoder/src/api/subscriptionApi.ts index db4599dc4..9525d2f75 100644 --- a/client/packages/lowcoder/src/api/subscriptionApi.ts +++ b/client/packages/lowcoder/src/api/subscriptionApi.ts @@ -1,303 +1,341 @@ -import Api from "api/api"; -import axios, { AxiosInstance, AxiosRequestConfig, CancelToken } from "axios"; -import { calculateFlowCode } from "./apiUtils"; +import Api from 'api/api' +import axios, { AxiosInstance, AxiosRequestConfig, CancelToken } from 'axios' +import { calculateFlowCode } from './apiUtils' import type { - LowcoderNewCustomer, - LowcoderSearchCustomer, - StripeCustomer, -} from "@lowcoder-ee/constants/subscriptionConstants"; + LowcoderNewCustomer, + LowcoderSearchCustomer, + StripeCustomer, +} from '@lowcoder-ee/constants/subscriptionConstants' export type ResponseType = { - response: any; -}; + response: any +} // Axios Configuration const lcHeaders = { - "Lowcoder-Token": calculateFlowCode(), - "Content-Type": "application/json" -}; + 'Lowcoder-Token': calculateFlowCode(), + 'Content-Type': 'application/json', +} -let axiosIns: AxiosInstance | null = null; +let axiosIns: AxiosInstance | null = null const getAxiosInstance = (clientSecret?: string) => { - if (axiosIns && !clientSecret) { - return axiosIns; - } + if (axiosIns && !clientSecret) { + return axiosIns + } - const headers: Record = { - "Content-Type": "application/json", - }; + const headers: Record = { + 'Content-Type': 'application/json', + } - const apiRequestConfig: AxiosRequestConfig = { - baseURL: "https://api-service.lowcoder.cloud/api/flow", - headers, - }; + const apiRequestConfig: AxiosRequestConfig = { + baseURL: 'https://api-service.lowcoder.cloud/api/flow', + headers, + } - axiosIns = axios.create(apiRequestConfig); - return axiosIns; -}; + axiosIns = axios.create(apiRequestConfig) + return axiosIns +} class SubscriptionApi 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 - }; + 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."); - }, 10000); - - response = await axiosInstance.request({ - ...requestConfig, - cancelToken: retrySource.token, - }); - - clearTimeout(retryTimeoutId); - } catch (retryError) { - console.warn("Error at Secure Flow Request. Retry failed:", retryError); - throw retryError; + 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.') + }, 10000) + + 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 } - } else { - console.warn("Error at Secure Flow Request:", error); - throw error; - } - } finally { - clearTimeout(timeoutId); // Clear the initial timeout - } - return response; - } + return response + } } // API Functions -export const searchCustomer = async (subscriptionCustomer: LowcoderSearchCustomer) => { - const apiBody = { - path: "webhook/secure/search-customer", - data: subscriptionCustomer, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data?.length === 1 ? result.data.data[0] as StripeCustomer : null; - } catch (error) { - console.error("Error searching customer:", error); - throw error; - } -}; +export const searchCustomer = async ( + subscriptionCustomer: LowcoderSearchCustomer +) => { + const apiBody = { + path: 'webhook/secure/search-customer', + data: subscriptionCustomer, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data?.length === 1 + ? (result.data.data[0] as StripeCustomer) + : null + } catch (error) { + console.error('Error searching customer:', error) + throw error + } +} export const searchSubscriptions = async (customerId: string) => { - const apiBody = { - path: "webhook/secure/search-subscriptions", - data: { customerId }, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data ?? []; - } catch (error) { - console.error("Error searching subscriptions:", error); - throw error; - } -}; - -export const searchCustomersSubscriptions = async (Customer: LowcoderSearchCustomer) => { - const apiBody = { - path: "webhook/secure/search-customersubscriptions", - data: Customer, - method: "post", - headers: lcHeaders - }; - - try { - const result = await SubscriptionApi.secureRequest(apiBody); - - if (!result || !result.data) { - return []; + const apiBody = { + path: 'webhook/secure/search-subscriptions', + data: { customerId }, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data ?? [] + } catch (error) { + console.error('Error searching subscriptions:', error) + throw error + } +} + +export const searchCustomersSubscriptions = async ( + Customer: LowcoderSearchCustomer +) => { + const apiBody = { + path: 'webhook/secure/search-customersubscriptions', + data: Customer, + method: 'post', + headers: lcHeaders, + } + + try { + const result = await SubscriptionApi.secureRequest(apiBody) + + if (!result || !result.data) { + return [] + } + + // Ensure result.data is an array before filtering + if (!Array.isArray(result.data)) { + console.warn( + 'searchCustomersSubscriptions: result.data is not an array', + result.data + ) + return [] + } + + // Filter out entries with `"success": "false"` + const validEntries = result.data.filter( + (entry: any) => entry.success !== 'false' + ) + + // Flatten the data arrays and filter out duplicates by `id` + const uniqueSubscriptions = Object.values( + validEntries.reduce((acc: Record, entry: any) => { + entry.data.forEach((subscription: any) => { + if (!acc[subscription.id]) { + acc[subscription.id] = subscription + } + }) + return acc + }, {}) + ) + + return uniqueSubscriptions + } catch (error) { + console.error('Error searching customer:', error) + throw error + } +} + +export const createCustomer = async ( + subscriptionCustomer: LowcoderNewCustomer +) => { + const apiBody = { + path: 'webhook/secure/create-customer', + data: subscriptionCustomer, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody, 15000) + return result?.data as StripeCustomer + } catch (error) { + console.error('Error creating customer:', error) + throw error + } +} + +export const cleanupCustomer = async ( + subscriptionCustomer: LowcoderSearchCustomer +) => { + const apiBody = { + path: 'webhook/secure/cleanup-customer', + data: subscriptionCustomer, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody, 15000) + return result?.data as any + } catch (error) { + console.error('Error creating customer:', error) + throw error } +} - // Filter out entries with `"success": "false"` - const validEntries = result.data?.filter((entry: any) => entry.success !== "false"); - - // Flatten the data arrays and filter out duplicates by `id` - const uniqueSubscriptions = Object.values( - validEntries.reduce((acc: Record, entry: any) => { - entry.data.forEach((subscription: any) => { - if (!acc[subscription.id]) { - acc[subscription.id] = subscription; - } - }); - return acc; - }, {}) - ); - - return uniqueSubscriptions; - } catch (error) { - console.error("Error searching customer:", error); - throw error; - } -}; - -export const createCustomer = async (subscriptionCustomer: LowcoderNewCustomer) => { - const apiBody = { - path: "webhook/secure/create-customer", - data: subscriptionCustomer, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody, 15000); - return result?.data as StripeCustomer; - } catch (error) { - console.error("Error creating customer:", error); - throw error; - } -}; - -export const cleanupCustomer = async (subscriptionCustomer: LowcoderSearchCustomer) => { - const apiBody = { - path: "webhook/secure/cleanup-customer", - data: subscriptionCustomer, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody, 15000); - return result?.data as any; - } catch (error) { - console.error("Error creating customer:", error); - throw error; - } -}; - -export const getProduct = async (productId : string) => { - const apiBody = { - path: "webhook/secure/get-product", - method: "post", - data: {"productId" : productId}, - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data as any; - } catch (error) { - console.error("Error fetching product:", error); - throw error; - } -}; +export const getProduct = async (productId: string) => { + const apiBody = { + path: 'webhook/secure/get-product', + method: 'post', + data: { productId: productId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data as any + } catch (error) { + console.error('Error fetching product:', error) + throw error + } +} export const getProducts = async () => { - const apiBody = { - path: "webhook/secure/get-products", - method: "post", - data: {}, - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data as any[]; - } catch (error) { - console.error("Error fetching product:", error); - throw error; - } -}; - -export const createCheckoutLink = async (customer: StripeCustomer, priceId: string, quantity: number, discount?: number) => { - const domain = window.location.protocol + "//" + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); - - const apiBody = { - path: "webhook/secure/create-checkout-link", - data: { - "customerId": customer.id, - "priceId": priceId, - "quantity": quantity, - "discount": discount, - baseUrl: domain - }, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data ? { id: result.data.id, url: result.data.url } : null; - } catch (error) { - console.error("Error creating checkout link:", error); - throw error; - } -}; + const apiBody = { + path: 'webhook/secure/get-products', + method: 'post', + data: {}, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data as any[] + } catch (error) { + console.error('Error fetching product:', error) + throw error + } +} + +export const createCheckoutLink = async ( + customer: StripeCustomer, + priceId: string, + quantity: number, + discount?: number +) => { + const domain = + window.location.protocol + + '//' + + window.location.hostname + + (window.location.port ? ':' + window.location.port : '') + + const apiBody = { + path: 'webhook/secure/create-checkout-link', + data: { + customerId: customer.id, + priceId: priceId, + quantity: quantity, + discount: discount, + baseUrl: domain, + }, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data + ? { id: result.data.id, url: result.data.url } + : null + } catch (error) { + console.error('Error creating checkout link:', error) + throw error + } +} // Function to get subscription details from Stripe export const getSubscriptionDetails = async (subscriptionId: string) => { - const apiBody = { - path: "webhook/secure/get-subscription-details", - method: "post", - data: { "subscriptionId": subscriptionId }, - headers: lcHeaders, - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data; - } catch (error) { - console.error("Error fetching subscription details:", error); - throw error; - } -}; + const apiBody = { + path: 'webhook/secure/get-subscription-details', + method: 'post', + data: { subscriptionId: subscriptionId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data + } catch (error) { + console.error('Error fetching subscription details:', error) + throw error + } +} // Function to get invoice documents from Stripe -export const getInvoices = async (subscriptionId: string) => { - const apiBody = { - path: "webhook/secure/get-subscription-invoices", - method: "post", - data: { "subscriptionId": subscriptionId }, - headers: lcHeaders, - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data ?? []; - } catch (error) { - console.error("Error fetching invoices:", error); - throw error; - } -}; +export const getInvoices = async (subscriptionId: string) => { + const apiBody = { + path: 'webhook/secure/get-subscription-invoices', + method: 'post', + data: { subscriptionId: subscriptionId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data ?? [] + } catch (error) { + console.error('Error fetching invoices:', error) + throw error + } +} // Function to get a customer Portal Session from Stripe -export const getCustomerPortalSession = async (customerId: string) => { - const apiBody = { - path: "webhook/secure/create-customer-portal-session", - method: "post", - data: { "customerId": customerId }, - headers: lcHeaders, - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data; - } catch (error) { - console.error("Error fetching invoices:", error); - throw error; - } -}; - -export default SubscriptionApi; +export const getCustomerPortalSession = async (customerId: string) => { + const apiBody = { + path: 'webhook/secure/create-customer-portal-session', + method: 'post', + data: { customerId: customerId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data + } catch (error) { + console.error('Error fetching invoices:', error) + throw error + } +} + +export default SubscriptionApi diff --git a/client/packages/lowcoder/src/app.tsx b/client/packages/lowcoder/src/app.tsx index a4857882e..df2970409 100644 --- a/client/packages/lowcoder/src/app.tsx +++ b/client/packages/lowcoder/src/app.tsx @@ -84,7 +84,7 @@ const Wrapper = React.memo((props: { const deploymentId = useSelector(getDeploymentId); const user = useSelector(getUser); const dispatch = useDispatch(); - + useEffect(() => { if (user.currentOrgId) { dispatch(fetchDeploymentIdAction()); @@ -92,22 +92,21 @@ const Wrapper = React.memo((props: { }, [user.currentOrgId]); useEffect(() => { - if(Boolean(deploymentId)) { + if (Boolean(deploymentId)) { dispatch(fetchSubscriptionsAction()) } }, [deploymentId]); - + const theme = useMemo(() => { return { hashed: false, token: { - fontFamily: `${ - props.fontFamily + fontFamily: `${props.fontFamily ? props.fontFamily.split('+').join(' ') : `-apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, "Segoe UI", "PingFang SC", "Microsoft Yahei", "Hiragino Sans GB", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"` - }, sans-serif`, + }, sans-serif`, }, } }, [props.fontFamily]); @@ -197,7 +196,7 @@ class AppIndex extends React.Component { {{this.props.brandName}} - {} + {/* Favicon is set per-route (admin vs. app). App routes handle their own via editorView.tsx */} { name="apple-mobile-web-app-title" content={this.props.brandName} /> - - + {/* App-specific apple-touch-icon is set within app routes; avoid a global conflicting tag here. */} { ]} - - - - - - - - - - - - - - - - - - - - - - - - - {this.props.isFetchUserFinished && this.props.defaultHomePage? ( - !this.props.orgDev ? ( - - ) : ( - - ) - ) : ( - - )} + + + - + - {developEnv() && ( - <> + + + - - - - )} - - + + + + + + + + + + + + + + {this.props.isFetchUserFinished && this.props.defaultHomePage ? ( + !this.props.orgDev ? ( + + ) : ( + + ) + ) : ( + + )} + + + + {developEnv() && ( + <> + + + + + + )} + + ); } diff --git a/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx b/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx index cccb2a1fc..8b63bfc12 100644 --- a/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx +++ b/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx @@ -1,27 +1,33 @@ +import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { findIconDefinition, library } from '@fortawesome/fontawesome-svg-core'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { far } from '@fortawesome/free-regular-svg-icons'; import * as AntdIcons from '@ant-design/icons'; -library.add(far,fas); +library.add(far, fas); + +export function parseIconIdentifier(identifier: string) { + // Handle null, undefined, or non-string values + if (!identifier || typeof identifier !== 'string') { + return { type: 'unknown', name: "" }; + } -function parseIconIdentifier(identifier: string) { if (identifier.startsWith('/icon:antd/')) { let name = identifier.split('/')[2]; return { type: 'antd', name }; - } + } else if (identifier.startsWith('/icon:solid/') || identifier.startsWith('/icon:regular/')) { const [style, name] = identifier.substring(6).split('/'); return { type: 'fontAwesome', style, name }; - } + } else if (identifier.startsWith('data:image')) { return { type: 'base64', data: identifier, name: "" }; - } + } else if (identifier.startsWith('http')) { return { type: 'url', url: identifier, name: "" }; - } + } else { return { type: 'unknown', name: "" }; } @@ -52,7 +58,7 @@ const appendStyleSuffix = (name: string) => { // Multi icon Display Component const baseMultiIconDisplay: React.FC = ({ identifier, width = '24px', height = '24px', style }) => { - + const iconData = parseIconIdentifier(identifier); if (iconData.type === 'fontAwesome') { @@ -65,21 +71,21 @@ const baseMultiIconDisplay: React.FC = ({ identifier, width = '24px', return null; } return ; - } + } else if (iconData.type === 'antd') { let iconName = convertToCamelCase(iconData.name); iconName = appendStyleSuffix(iconName); - iconName = iconName.charAt(0).toUpperCase() + iconName.slice(1); + iconName = iconName.charAt(0).toUpperCase() + iconName.slice(1); const AntdIcon = (AntdIcons as any)[iconName]; if (!AntdIcon) { console.error(`ANTd Icon ${iconData.name} not found`); return null; } return ; - } + } else if (iconData.type === 'url' || iconData.type === 'base64') { return icon; - } + } else { return null; // Unknown type } diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx index 20ba7f5ab..c98e687f2 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx @@ -32,7 +32,10 @@ import { UserIcon, } from "lowcoder-design"; import React, { useCallback, useEffect, useState, useMemo } from "react"; +import { Helmet } from "react-helmet"; import { fetchHomeData } from "redux/reduxActions/applicationActions"; +import { getBrandingConfig } from "redux/selectors/configSelectors"; +import { buildMaterialPreviewURL } from "util/materialUtils"; import { fetchSubscriptionsAction } from "redux/reduxActions/subscriptionActions"; import { getHomeOrg, normalAppListSelector } from "redux/selectors/applicationSelector"; import { DatasourceHome } from "../datasource"; @@ -61,11 +64,12 @@ import { SubscriptionProductsEnum } from '@lowcoder-ee/constants/subscriptionCon import { EnterpriseProvider } from "@lowcoder-ee/util/context/EnterpriseContext"; import { SimpleSubscriptionContextProvider } from "@lowcoder-ee/util/context/SimpleSubscriptionContext"; import { selectIsLicenseActive } from "redux/selectors/enterpriseSelectors"; +// (deduped imports removed) // adding App Editor, so we can show Apps inside the Admin Area import AppEditor from "../editor/AppEditor"; -import {LoadingBarHideTrigger} from "@lowcoder-ee/util/hideLoading"; +import { LoadingBarHideTrigger } from "@lowcoder-ee/util/hideLoading"; const TabLabel = styled.div` font-weight: 500; @@ -90,6 +94,7 @@ export default function ApplicationHome() { const allFolders = useSelector(foldersSelector); const user = useSelector(getUser); const org = useSelector(getHomeOrg); + const brandingConfig = useSelector(getBrandingConfig); const allAppCount = allApplications.length; const allFoldersCount = allFolders.length; const orgHomeId = "root"; @@ -130,151 +135,159 @@ export default function ApplicationHome() { return ( + + {/* Default favicon for admin/non-app routes */} + + {/* */} - {/* */} - */} + {trans("home.profile")}, - routePath: USER_PROFILE_URL, - routeComp: UserProfileView, - icon: ({ selected, ...otherProps }) => selected ? : , - }, - { - text: {trans("home.news")}, - routePath: NEWS_URL, - routeComp: NewsView, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - style: { color: "red" }, - }, - { - text: {trans("home.orgHome")}, - routePath: ORG_HOME_URL, - routePathExact: false, - routeComp: OrgView, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => !user.orgDev, - }, - { - text: {trans("home.marketplace")}, - routePath: MARKETPLACE_URL, - routePathExact: false, - routeComp: MarketplaceView, - icon: ({ selected, ...otherProps }) => selected ? : , - }, - ] + text: {trans("home.profile")}, + routePath: USER_PROFILE_URL, + routeComp: UserProfileView, + icon: ({ selected, ...otherProps }) => selected ? : , }, - { - items: [ - { - text: {trans("home.allApplications")}, - routePath: ALL_APPLICATIONS_URL, - routeComp: HomeView, - icon: ({ selected, ...otherProps }) => selected ? : , - onSelected: (_, currentPath) => currentPath === ALL_APPLICATIONS_URL || currentPath.startsWith("/folder"), - }, - ], + text: {trans("home.news")}, + routePath: NEWS_URL, + routeComp: NewsView, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + style: { color: "red" }, }, - { - items: [ - { - text: {trans("home.datasource")}, - routePath: DATASOURCE_URL, - routePathExact: false, - routeComp: DatasourceHome, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - onSelected: (_, currentPath) => currentPath.split("/")[1] === "datasource", - mobileVisible: false, - }, - { - text: {trans("home.queryLibrary")}, - routePath: QUERY_LIBRARY_URL, - routeComp: QueryLibraryEditor, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - mobileVisible: false, - } - ], + text: {trans("home.orgHome")}, + routePath: ORG_HOME_URL, + routePathExact: false, + routeComp: OrgView, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => !user.orgDev, }, - - // Show Subscription if not yet subscribed else Support Pages - { - items: [ - { - text: {trans("home.support")}, - routePath: supportSubscription ? SUPPORT_URL : SUBSCRIPTION_SETTING, - routeComp: supportSubscription ? Support : Setting, - routePathExact: false, - icon: ({ selected, ...otherProps }) => , - mobileVisible: true, - visible: ({ user }) => user.orgDev - } - ] + text: {trans("home.marketplace")}, + routePath: MARKETPLACE_URL, + routePathExact: false, + routeComp: MarketplaceView, + icon: ({ selected, ...otherProps }) => selected ? : , }, + ] + }, + + { + items: [ { - items: [ - { - text: {trans("environments.detail_enterpriseEdition")}, - routePath: ENVIRONMENT_SETTING, - routeComp: Setting, - routePathExact: false, - icon: ({ selected, ...otherProps }) => , - mobileVisible: true, - visible: () => !isLicenseActive, - style: { color: "#ff6f3c" }, - } - ] + text: {trans("home.allApplications")}, + routePath: ALL_APPLICATIONS_URL, + routeComp: HomeView, + icon: ({ selected, ...otherProps }) => selected ? : , + onSelected: (_, currentPath) => currentPath === ALL_APPLICATIONS_URL || currentPath.startsWith("/folder"), }, + ], + }, + { + items: [ { - items: [ - { - text: {trans("settings.title")}, - routePath: SETTING_URL, - routePathExact: false, - routeComp: Setting, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - onSelected: (_, currentPath) => currentPath.split("/")[1] === "setting", - } - ] + text: {trans("home.datasource")}, + routePath: DATASOURCE_URL, + routePathExact: false, + routeComp: DatasourceHome, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + onSelected: (_, currentPath) => currentPath.split("/")[1] === "datasource", + mobileVisible: false, }, + { + text: {trans("home.queryLibrary")}, + routePath: QUERY_LIBRARY_URL, + routeComp: QueryLibraryEditor, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + mobileVisible: false, + } + ], + }, + + // Show Subscription if not yet subscribed else Support Pages + + { + items: [ + { + text: {trans("home.support")}, + routePath: supportSubscription ? SUPPORT_URL : SUBSCRIPTION_SETTING, + routeComp: supportSubscription ? Support : Setting, + routePathExact: false, + icon: ({ selected, ...otherProps }) => , + mobileVisible: true, + visible: ({ user }) => user.orgDev + } + ] + }, + { + items: [ + { + text: {trans("environments.detail_enterpriseEdition")}, + routePath: ENVIRONMENT_SETTING, + routeComp: Setting, + routePathExact: false, + icon: ({ selected, ...otherProps }) => , + mobileVisible: true, + visible: () => !isLicenseActive, + style: { color: "#ff6f3c" }, + } + ] + }, + + { + items: [ + { + text: {trans("settings.title")}, + routePath: SETTING_URL, + routePathExact: false, + routeComp: Setting, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + onSelected: (_, currentPath) => currentPath.split("/")[1] === "setting", + } + ] + }, + { + items: [ { - items: [ - { - text: {trans("home.trash")}, - routePath: TRASH_URL, - routeComp: TrashView, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - }, - ], + text: {trans("home.trash")}, + routePath: TRASH_URL, + routeComp: TrashView, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, }, + ], + }, - // this we need to show the Folders view in the Admin Area + // this we need to show the Folders view in the Admin Area + { + items: [ { - items: [ - { - text: "", - routePath: FOLDER_URL, - routeComp: FolderView, - visible: () => false, - } - ] + text: "", + routePath: FOLDER_URL, + routeComp: FolderView, + visible: () => false, } + ] + } - ]} - /> - {/* */} + ]} + /> + {/* */} {/* */} diff --git a/client/packages/lowcoder/src/pages/editor/editorView.tsx b/client/packages/lowcoder/src/pages/editor/editorView.tsx index c722f907f..69f31ca9c 100644 --- a/client/packages/lowcoder/src/pages/editor/editorView.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorView.tsx @@ -1,6 +1,6 @@ import { default as Divider } from "antd/es/divider"; import { default as Menu } from "antd/es/menu"; -import { default as Sider} from "antd/es/layout/Sider"; +import { default as Sider } from "antd/es/layout/Sider"; import { PreloadComp } from "comps/comps/preLoadComp"; import UIComp from "comps/comps/uiComp"; import { EditorContext } from "comps/editorState"; @@ -63,73 +63,75 @@ import { import { isEqual, noop } from "lodash"; import { AppSettingContext, AppSettingType } from "@lowcoder-ee/comps/utils/appSettingContext"; import { getBrandingSetting } from "@lowcoder-ee/redux/selectors/enterpriseSelectors"; +import { getBrandingConfig } from "redux/selectors/configSelectors"; import Flex from "antd/es/flex"; +import { getAppFavicon, getOgImageUrl } from "util/iconConversionUtils"; // import { BottomSkeleton } from "./bottom/BottomContent"; const Header = lazy( - () => import("pages/common/header") - .then(module => ({default: module.default})) + () => import("pages/common/header") + .then(module => ({ default: module.default })) ); const BottomSkeleton = lazy( - () => import("pages/editor/bottom/BottomContent") - .then(module => ({default: module.BottomSkeleton})) + () => import("pages/editor/bottom/BottomContent") + .then(module => ({ default: module.BottomSkeleton })) ); const LeftContent = lazy( () => import('./LeftContent') - .then(module => ({default: module.LeftContent})) + .then(module => ({ default: module.LeftContent })) ); const LeftLayersContent = lazy( () => import('./LeftLayersContent') - .then(module => ({default: module.LeftLayersContent})) + .then(module => ({ default: module.LeftLayersContent })) ); const RightPanel = lazy(() => import('pages/editor/right/RightPanel')); const EditorTutorials = lazy(() => import('pages/tutorials/editorTutorials')); const Bottom = lazy(() => import('./bottom/BottomPanel')); const CustomShortcutWrapper = lazy( () => import('pages/editor/editorHotKeys') - .then(module => ({default: module.CustomShortcutWrapper})) + .then(module => ({ default: module.CustomShortcutWrapper })) ); const EditorGlobalHotKeys = lazy( () => import('pages/editor/editorHotKeys') - .then(module => ({default: module.EditorGlobalHotKeys})) + .then(module => ({ default: module.EditorGlobalHotKeys })) ); const EditorHotKeys = lazy( () => import('pages/editor/editorHotKeys') - .then(module => ({default: module.EditorHotKeys})) + .then(module => ({ default: module.EditorHotKeys })) ); const Body = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.Body})) + .then(module => ({ default: module.Body })) ); const EditorContainer = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.EditorContainer})) + .then(module => ({ default: module.EditorContainer })) ); const EditorContainerWithViewMode = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.EditorContainerWithViewMode})) + .then(module => ({ default: module.EditorContainerWithViewMode })) ); const Height100Div = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.Height100Div})) + .then(module => ({ default: module.Height100Div })) ); const LeftPanel = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.LeftPanel})) + .then(module => ({ default: module.LeftPanel })) ); const MiddlePanel = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.MiddlePanel})) + .then(module => ({ default: module.MiddlePanel })) ); const HelpDropdown = lazy( () => import('pages/common/help') - .then(module => ({default: module.HelpDropdown})) + .then(module => ({ default: module.HelpDropdown })) ); const PreviewHeader = lazy( () => import('pages/common/previewHeader') - .then(module => ({default: module.PreviewHeader})) + .then(module => ({ default: module.PreviewHeader })) ); const HookCompContainer = styled.div` @@ -144,9 +146,8 @@ const HookCompContainer = styled.div` `; const ViewBody = styled.div<{ $hideBodyHeader?: boolean; $height?: number }>` - height: ${(props) => `calc(${ - props.$height ? props.$height + "px" : "100vh" - } - env(safe-area-inset-bottom) - + height: ${(props) => `calc(${props.$height ? props.$height + "px" : "100vh" + } - env(safe-area-inset-bottom) - ${props.$hideBodyHeader ? "0px" : TopHeaderHeight} )`}; `; @@ -414,6 +415,7 @@ function EditorView(props: EditorViewProps) { const showNewUserGuide = locationState?.showNewUserGuide; const showAppSnapshot = useSelector(showAppSnapshotSelector); const brandingSettings = useSelector(getBrandingSetting); + const brandingConfig = useSelector(getBrandingConfig); const [showShortcutList, setShowShortcutList] = useState(false); const toggleShortcutList = useCallback( () => setShowShortcutList(!showShortcutList), @@ -515,7 +517,7 @@ function EditorView(props: EditorViewProps) { ); } - + return uiComp.getView(); }, [ showAppSnapshot, @@ -530,10 +532,10 @@ function EditorView(props: EditorViewProps) { return ( editorState.deviceType === "mobile" || editorState.deviceType === "tablet" ? ( - {uiComp.getView()} + deviceType={editorState.deviceType} + deviceOrientation={editorState.deviceOrientation} + > + {uiComp.getView()} ) : (
@@ -565,6 +567,26 @@ function EditorView(props: EditorViewProps) { if (readOnly && hideHeader) { return ( + + {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} + {(() => { + const appId = application?.applicationId; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const appIcon512 = appId ? `/api/applications/${appId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` : undefined; + const appIcon192 = appId ? `/api/applications/${appId}/icons/192.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` : undefined; + const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined; + const themeColor = brandBg || '#b480de'; + return [ + manifestHref && , + appIcon192 && , + appIcon512 && , + appIcon512 && , + appIcon512 && , + appIcon512 && , + , + ]; + })()} + {uiComp.getView()}
{hookCompViews}
@@ -575,23 +597,70 @@ function EditorView(props: EditorViewProps) { return ( - {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} - {isLowCoderDomain || isLocalhost && [ - // Adding Support for iframely to be able to embedd apps as iframes - application?.name ? ([ - , - , - ]) : ([ - , - , - ]), - , - , - , - , - // adding Hubspot Support for Analytics - - ]} + {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} + {application && (() => { + const appFavicon = getAppFavicon(appSettingsComp, application.applicationId); + if (appFavicon) { + const bg = brandingSettings?.config_set?.mainBrandingColor; + const href = bg ? `${appFavicon}?bg=${encodeURIComponent(bg)}` : appFavicon; + return ; + } else { + // Render default favicon only when no app-specific favicon is available + const defaultFavicon = brandingConfig?.favicon || "/src/assets/images/favicon.ico"; + return ; + } + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const appleTouchIcon: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const startupImage: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && ( + + )} + {application && ( + + )} + {application && ( + + )} + {application && (() => { + const og = getOgImageUrl(application.applicationId, brandingSettings?.config_set?.mainBrandingColor); + return [ + , + , + ]; + })()} + + + + + + + }> {!hideBodyHeader && } @@ -607,7 +676,7 @@ function EditorView(props: EditorViewProps) { ); } - + // history mode, display with the right panel, a little trick const showRight = panelStatus.right || showAppSnapshot; @@ -623,32 +692,74 @@ function EditorView(props: EditorViewProps) { return ( <> - - {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} - {isLowCoderDomain || isLocalhost && [ - // Adding Support for iframely to be able to embedd apps as iframes - application?.name ? ([ - , - , - ]) : ([ - , - , - ]), - , - , - , - , - // adding Clearbit Support for Analytics + + {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} + {application && (() => { + const appFavicon = getAppFavicon(appSettingsComp, application.applicationId); + if (appFavicon) { + const bg = brandingSettings?.config_set?.mainBrandingColor; + const href = bg ? `${appFavicon}?bg=${encodeURIComponent(bg)}` : appFavicon; + return ; + } else { + const defaultFavicon = brandingConfig?.favicon || "/src/assets/images/favicon.ico"; + return ; + } + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const appleTouchIcon: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const startupImage: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && ( + + )} + {application && ( + + )} + {application && ( + + )} + {application && (() => { + const og = getOgImageUrl(application.applicationId, brandingSettings?.config_set?.mainBrandingColor); + return [ + , + , + ]; + })()} + + + + + + - ]} - - { - // log.debug("layout: onDragEnd. Height100Div"); - editorState.setDragging(false); - draggingUtils.clearData(); - } } - > + + { + // log.debug("layout: onDragEnd. Height100Div"); + editorState.setDragging(false); + draggingUtils.clearData(); + }} + > {isPublicApp ? : ( @@ -704,7 +815,7 @@ function EditorView(props: EditorViewProps) { {panelStatus.left && editorModeStatus !== "layout" && ( {menuKey === SiderKey.State && } - + <> {menuKey === SiderKey.Setting && ( @@ -748,7 +859,7 @@ function EditorView(props: EditorViewProps) { {trans("leftPanel.toolbarPreload")} - + {props.preloadComp.getJSLibraryPropertyView()} )} diff --git a/client/packages/lowcoder/src/util/iconConversionUtils.ts b/client/packages/lowcoder/src/util/iconConversionUtils.ts new file mode 100644 index 000000000..522ed6bd6 --- /dev/null +++ b/client/packages/lowcoder/src/util/iconConversionUtils.ts @@ -0,0 +1,138 @@ +import { parseIconIdentifier } from 'comps/comps/multiIconDisplay' + +/** + * Utility functions for handling app-specific favicon and icon conversion + */ + +export interface AppIconInfo { + type: 'antd' | 'fontAwesome' | 'base64' | 'url' | 'unknown' + identifier: string + name?: string + url?: string + data?: string +} + +/** + * Extract app icon information from app settings + */ +export function getAppIconInfo(appSettingsComp: any): AppIconInfo | null { + if (!appSettingsComp?.children?.icon?.getView) { + return null + } + + const iconIdentifier = appSettingsComp.children.icon.getView() + + if (!iconIdentifier) { + return null + } + + // If the identifier is an object, try to extract the string value + let iconString = iconIdentifier + if (typeof iconIdentifier === 'object') { + // Check if it's a React element + if (iconIdentifier.$$typeof === Symbol.for('react.element')) { + // Try to extract icon information from React element props + if (iconIdentifier.props && iconIdentifier.props.value) { + // For URL-based icons, the value contains the URL + iconString = iconIdentifier.props.value + } else if (iconIdentifier.props && iconIdentifier.props.icon) { + iconString = iconIdentifier.props.icon + } else if (iconIdentifier.props && iconIdentifier.props.type) { + // For Ant Design icons, the type might be in props.type + iconString = iconIdentifier.props.type + } else { + return null + } + } else { + // Try to get the string value from the object + if (iconIdentifier.value !== undefined) { + iconString = iconIdentifier.value + } else if (iconIdentifier.toString) { + iconString = iconIdentifier.toString() + } else { + return null + } + } + } + + const parsed = parseIconIdentifier(iconString) + + return { + type: parsed.type as AppIconInfo['type'], + identifier: iconString, + name: parsed.name, + url: parsed.url, + data: parsed.data, + } +} + +/** + * Generate favicon URL for an app + * This is a simple implementation that returns the icon as-is for now + * In Phase 2, this will be replaced with actual icon conversion logic + */ +export function getAppFaviconUrl(appId: string, iconInfo: AppIconInfo): string { + // Use backend PNG conversion endpoint for consistent, cacheable favicons + // The backend handles data URLs/HTTP images and falls back gracefully + return `/api/applications/${appId}/icons/192.png` +} + +/** + * Check if an icon can be used as a favicon + */ +export function canUseAsFavicon(iconInfo: AppIconInfo): boolean { + switch (iconInfo.type) { + case 'url': + case 'base64': + return true + case 'antd': + case 'fontAwesome': + // These need conversion to be used as favicon + return false + default: + return false + } +} + +/** + * Get the appropriate favicon for an app + * Returns the app-specific favicon if available, otherwise null + */ +export function getAppFavicon( + appSettingsComp: any, + appId: string +): string | null { + const iconInfo = getAppIconInfo(appSettingsComp) + + if (!iconInfo) { + return null + } + + // Always prefer the backend-rendered PNG for a reliable favicon + return getAppFaviconUrl(appId, iconInfo) +} + +/** + * Build the backend PNG icon URL for a given size and optional background color. + * Pass backgroundHex with or without leading '#'. + */ +export function getAppIconPngUrl( + appId: string, + size: number, + backgroundHex?: string +): string { + const base = `/api/applications/${appId}/icons/${size}.png` + if (!backgroundHex) return base + const clean = backgroundHex.startsWith('#') + ? backgroundHex + : `#${backgroundHex}` + const bg = encodeURIComponent(clean) + return `${base}?bg=${bg}` +} + +/** + * Convenience URL for share previews (Open Graph / Twitter), using 512 size. + */ +export function getOgImageUrl(appId: string, backgroundHex?: string): string { + return getAppIconPngUrl(appId, 512, backgroundHex) +} From e9232cb0f000b5303f4d71b29230eebb9dad93ea Mon Sep 17 00:00:00 2001 From: Elier Herrera Date: Sat, 9 Aug 2025 00:04:46 -0400 Subject: [PATCH 12/13] feat(pwa-server): per-app icon endpoints, manifest security scope, and local-dev config; refactor ApplicationController; tests updated --- server/api-service/lowcoder-server/pom.xml | 5 - .../api/OpenAPIDocsConfiguration.java | 4 +- .../api/application/AppIconController.java | 244 ++++++++++++++++++ .../application/ApplicationController.java | 106 ++++++++ .../api/application/ApplicationEndpoints.java | 10 + .../framework/security/SecurityConfig.java | 12 + .../application-lowcoder-local-dev.yml | 8 + .../application/ApplicationEndpointsTest.java | 6 +- 8 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/AppIconController.java create mode 100644 server/api-service/lowcoder-server/src/main/resources/application-lowcoder-local-dev.yml diff --git a/server/api-service/lowcoder-server/pom.xml b/server/api-service/lowcoder-server/pom.xml index ccfb6998a..dd5994f8d 100644 --- a/server/api-service/lowcoder-server/pom.xml +++ b/server/api-service/lowcoder-server/pom.xml @@ -77,11 +77,6 @@ org.springdoc springdoc-openapi-starter-webflux-ui - - org.springdoc - springdoc-openapi-webflux-ui - 1.8.0 - io.projectreactor.tools blockhound diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/OpenAPIDocsConfiguration.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/OpenAPIDocsConfiguration.java index 877dd4fc2..a898c4a3a 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/OpenAPIDocsConfiguration.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/OpenAPIDocsConfiguration.java @@ -12,7 +12,7 @@ import io.swagger.v3.oas.models.servers.ServerVariables; import io.swagger.v3.oas.models.tags.Tag; import org.lowcoder.sdk.config.CommonConfig; -import org.springdoc.core.customizers.OpenApiCustomiser; +import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -135,7 +135,7 @@ private Server createCustomServer() { * Customizes the OpenAPI spec at runtime to sort tags and paths. */ @Bean - public OpenApiCustomiser sortOpenApiSpec() { + public OpenApiCustomizer sortOpenApiSpec() { return openApi -> { // Sort tags alphabetically if (openApi.getTags() != null) { diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/AppIconController.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/AppIconController.java new file mode 100644 index 000000000..52a76aa72 --- /dev/null +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/AppIconController.java @@ -0,0 +1,244 @@ +package org.lowcoder.api.application; + +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.infra.constant.NewUrl; +import org.lowcoder.infra.constant.Url; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Serves per-application icons and PWA manifest. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping({Url.APPLICATION_URL, NewUrl.APPLICATION_URL}) +@Slf4j +public class AppIconController { + + private static final List ALLOWED_SIZES = List.of(48, 72, 96, 120, 128, 144, 152, 167, 180, 192, 256, 384, 512); + + private final ApplicationService applicationService; + private final ApplicationRecordService applicationRecordService; + + private static final long CACHE_TTL_MILLIS = Duration.ofHours(12).toMillis(); + private static final int CACHE_MAX_ENTRIES = 2000; + private static final Map ICON_CACHE = new ConcurrentHashMap<>(); + + private record CacheEntry(byte[] data, long expiresAtMs) {} + + private static String buildCacheKey(String applicationId, String iconIdentifier, String appName, int size, @Nullable Color bgColor) { + String id = (iconIdentifier == null || iconIdentifier.isBlank()) ? ("placeholder:" + Objects.toString(appName, "Lowcoder")) : iconIdentifier; + String bg = (bgColor == null) ? "none" : (bgColor.getRed()+","+bgColor.getGreen()+","+bgColor.getBlue()); + return applicationId + "|" + id + "|" + size + "|" + bg; + } + + @GetMapping("/{applicationId}/icons") + public Mono>> getAvailableIconSizes(@PathVariable String applicationId) { + Map payload = new HashMap<>(); + payload.put("sizes", ALLOWED_SIZES); + return Mono.just(ResponseView.success(payload)); + } + + @GetMapping("/{applicationId}/icons/{size}.png") + public Mono getIconPng(@PathVariable String applicationId, + @PathVariable int size, + @RequestParam(name = "bg", required = false) String bg, + ServerHttpResponse response) { + if (!ALLOWED_SIZES.contains(size)) { + // clamp to a safe default + int fallback = 192; + return getIconPng(applicationId, fallback, bg, response); + } + + response.getHeaders().setContentType(MediaType.IMAGE_PNG); + response.getHeaders().setCacheControl(CacheControl.maxAge(Duration.ofDays(7)).cachePublic()); + + final Color bgColor = parseColor(bg); + + return applicationService.findById(applicationId) + .flatMap(app -> Mono.zip(Mono.just(app), app.getIcon(applicationRecordService))) + .flatMap(tuple -> { + Application app = tuple.getT1(); + String iconIdentifier = Optional.ofNullable(tuple.getT2()).orElse(""); + String cacheKey = buildCacheKey(applicationId, iconIdentifier, app.getName(), size, bgColor); + + // Cache hit + CacheEntry cached = ICON_CACHE.get(cacheKey); + if (cached != null && cached.expiresAtMs() > System.currentTimeMillis()) { + byte[] bytes = cached.data(); + return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then(); + } + + // Cache miss: render and store + return Mono.fromCallable(() -> buildIconPng(iconIdentifier, app.getName(), size, bgColor)) + .onErrorResume(e -> { + log.warn("Failed to generate icon for app {}: {}", applicationId, e.getMessage()); + return Mono.fromCallable(() -> buildPlaceholderPng(app.getName(), size, bgColor)); + }) + .flatMap(bytes -> { + putInCache(cacheKey, bytes); + return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then(); + }); + }) + .switchIfEmpty(Mono.defer(() -> { + String cacheKey = buildCacheKey(applicationId, "", "Lowcoder", size, bgColor); + CacheEntry cached = ICON_CACHE.get(cacheKey); + if (cached != null && cached.expiresAtMs() > System.currentTimeMillis()) { + byte[] bytes = cached.data(); + return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then(); + } + byte[] bytes = buildPlaceholderPng("Lowcoder", size, bgColor); + putInCache(cacheKey, bytes); + return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then(); + })); + } + + private static void putInCache(String key, byte[] data) { + long expires = System.currentTimeMillis() + CACHE_TTL_MILLIS; + if (ICON_CACHE.size() >= CACHE_MAX_ENTRIES) { + // Best-effort cleanup of expired entries; if still large, remove one arbitrary entry + ICON_CACHE.entrySet().removeIf(e -> e.getValue().expiresAtMs() <= System.currentTimeMillis()); + if (ICON_CACHE.size() >= CACHE_MAX_ENTRIES) { + String firstKey = ICON_CACHE.keySet().stream().findFirst().orElse(null); + if (firstKey != null) ICON_CACHE.remove(firstKey); + } + } + ICON_CACHE.put(key, new CacheEntry(data, expires)); + } + + private static byte[] buildIconPng(String iconIdentifier, String appName, int size, @Nullable Color bgColor) throws Exception { + BufferedImage source = tryLoadImage(iconIdentifier); + if (source == null) { + return buildPlaceholderPng(appName, size, bgColor); + } + return scaleToSquarePng(source, size, bgColor); + } + + private static BufferedImage tryLoadImage(String iconIdentifier) { + if (iconIdentifier == null || iconIdentifier.isBlank()) return null; + try { + if (iconIdentifier.startsWith("data:image")) { + String base64 = iconIdentifier.substring(iconIdentifier.indexOf(",") + 1); + byte[] data = Base64.getDecoder().decode(base64); + try (InputStream in = new ByteArrayInputStream(data)) { + return ImageIO.read(in); + } + } + if (iconIdentifier.startsWith("http://") || iconIdentifier.startsWith("https://")) { + try (InputStream in = new URL(iconIdentifier).openStream()) { + return ImageIO.read(in); + } + } + } catch (Exception e) { + // ignore and fallback + } + return null; + } + + private static byte[] scaleToSquarePng(BufferedImage source, int size, @Nullable Color bgColor) throws Exception { + int w = source.getWidth(); + int h = source.getHeight(); + double scale = Math.min((double) size / w, (double) size / h); + int newW = Math.max(1, (int) Math.round(w * scale)); + int newH = Math.max(1, (int) Math.round(h * scale)); + + BufferedImage canvas = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = canvas.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + if (bgColor != null) { + g.setColor(bgColor); + g.fillRect(0, 0, size, size); + } + int x = (size - newW) / 2; + int y = (size - newH) / 2; + g.drawImage(source, x, y, newW, newH, null); + } finally { + g.dispose(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(canvas, "png", baos); + return baos.toByteArray(); + } + + private static byte[] buildPlaceholderPng(String appName, int size, @Nullable Color bgColor) { + try { + BufferedImage canvas = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = canvas.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + Color background = bgColor != null ? bgColor : new Color(0xB4, 0x80, 0xDE); // #b480de + g.setColor(background); + g.fillRect(0, 0, size, size); + // draw first letter as simple placeholder + String letter = (appName != null && !appName.isBlank()) ? appName.substring(0, 1).toUpperCase() : "L"; + g.setColor(Color.WHITE); + int fontSize = Math.max(24, (int) (size * 0.5)); + g.setFont(new Font("SansSerif", Font.BOLD, fontSize)); + FontMetrics fm = g.getFontMetrics(); + int textW = fm.stringWidth(letter); + int textH = fm.getAscent(); + int x = (size - textW) / 2; + int y = (size + textH / 2) / 2; + g.drawString(letter, x, y); + } finally { + g.dispose(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(canvas, "png", baos); + return baos.toByteArray(); + } catch (Exception e) { + // last resort + return new byte[0]; + } + } + + @Nullable + private static Color parseColor(@Nullable String hex) { + if (hex == null || hex.isBlank()) return null; + String v = hex.trim(); + if (v.startsWith("#")) v = v.substring(1); + try { + if (v.length() == 6) { + int r = Integer.parseInt(v.substring(0, 2), 16); + int g = Integer.parseInt(v.substring(2, 4), 16); + int b = Integer.parseInt(v.substring(4, 6), 16); + return new Color(r, g, b); + } + } catch (Exception ignored) { + } + return null; + } + + +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationController.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationController.java index cfec54a13..7311b009e 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationController.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationController.java @@ -14,6 +14,7 @@ import org.lowcoder.domain.application.model.ApplicationType; import org.lowcoder.domain.application.service.ApplicationRecordService; import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.application.service.ApplicationService; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -29,6 +30,14 @@ import static org.lowcoder.sdk.util.ExceptionUtils.ofError; import reactor.core.publisher.Flux; import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.util.ArrayList; +import java.util.HashMap; @RequiredArgsConstructor @RestController @@ -39,6 +48,7 @@ public class ApplicationController implements ApplicationEndpoints { private final BusinessEventPublisher businessEventPublisher; private final GidService gidService; private final ApplicationRecordService applicationRecordService; + private final ApplicationService applicationService; @Override public Mono> create(@RequestBody CreateApplicationRequest createApplicationRequest) { @@ -332,4 +342,100 @@ public Mono>> getGroupsOrMembersWithoutPermissions( .map(tuple -> PageResponseView.success(tuple.getT1(), pageNum, pageSize, Math.toIntExact(tuple.getT2()))); }); } + + @Override + @GetMapping("/{applicationId}/manifest.json") + public Mono> getApplicationManifest(@PathVariable String applicationId) { + return gidService.convertApplicationIdToObjectId(applicationId).flatMap(appId -> + // Prefer published DSL; if absent, fall back to current editing DSL directly from DB + applicationRecordService.getLatestRecordByApplicationId(appId) + .map(record -> record.getApplicationDSL()) + .switchIfEmpty( + applicationService.findById(appId) + .map(app -> app.getEditingApplicationDSL()) + ) + .map(dsl -> { + Map safeDsl = dsl == null ? new HashMap<>() : dsl; + Map settings = (Map) safeDsl.get("settings"); + + String defaultName = "Lowcoder"; + String appTitle = defaultName; + if (settings != null) { + Object titleObj = settings.get("title"); + if (titleObj instanceof String) { + String t = (String) titleObj; + if (!t.isBlank()) { + appTitle = t; + } + } + } + String appDescription = settings != null && settings.get("description") instanceof String + ? (String) settings.get("description") + : ""; + if (appDescription == null) appDescription = ""; + String appIcon = settings != null ? (String) settings.get("icon") : ""; + + // Generate manifest JSON + Map manifest = new HashMap<>(); + manifest.put("name", appTitle); + manifest.put("short_name", appTitle != null && appTitle.length() > 12 ? appTitle.substring(0, 12) : (appTitle == null ? "" : appTitle)); + manifest.put("description", appDescription); + // PWA routing: open the installed app directly to the public view of this application + String appBasePath = "/apps/" + applicationId; + String appStartUrl = appBasePath + "/view"; + manifest.put("id", appBasePath); + manifest.put("start_url", appStartUrl); + manifest.put("scope", appBasePath + "/"); + manifest.put("display", "standalone"); + manifest.put("theme_color", "#b480de"); + manifest.put("background_color", "#ffffff"); + + // Generate icons array (serve via icon endpoints that render PNGs) + List> icons = new ArrayList<>(); + int[] sizes = new int[] {48, 72, 96, 120, 128, 144, 152, 167, 180, 192, 256, 384, 512}; + for (int s : sizes) { + Map icon = new HashMap<>(); + icon.put("src", "/api/applications/" + applicationId + "/icons/" + s + ".png"); + icon.put("sizes", s + "x" + s); + icon.put("type", "image/png"); + icon.put("purpose", "any maskable"); + icons.add(icon); + } + manifest.put("icons", icons); + + // Optional categories for better store/system grouping + List categories = new ArrayList<>(); + categories.add("productivity"); + categories.add("business"); + manifest.put("categories", categories); + + // Add shortcuts for quick actions + List> shortcuts = new ArrayList<>(); + // View (start) shortcut + Map viewShortcut = new HashMap<>(); + viewShortcut.put("name", appTitle); + viewShortcut.put("short_name", appTitle != null && appTitle.length() > 12 ? appTitle.substring(0, 12) : (appTitle == null ? "" : appTitle)); + viewShortcut.put("description", appDescription); + viewShortcut.put("url", appStartUrl); + shortcuts.add(viewShortcut); + // Edit shortcut (may require auth) + Map editShortcut = new HashMap<>(); + editShortcut.put("name", "Edit application"); + editShortcut.put("short_name", "Edit"); + editShortcut.put("description", "Open the application editor"); + editShortcut.put("url", appBasePath); + shortcuts.add(editShortcut); + manifest.put("shortcuts", shortcuts); + + try { + return ResponseEntity.ok() + .contentType(MediaType.valueOf("application/manifest+json")) + .body(new ObjectMapper().writeValueAsString(manifest)); + } catch (JsonProcessingException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{}"); + } + }) + .onErrorReturn(ResponseEntity.status(HttpStatus.NOT_FOUND).body("{}")) + ); + } } diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationEndpoints.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationEndpoints.java index c3ee2c1dc..9ed34c4ad 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationEndpoints.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationEndpoints.java @@ -15,6 +15,7 @@ import org.lowcoder.infra.constant.Url; import org.lowcoder.sdk.config.JsonViews; import org.springframework.web.bind.annotation.*; +import org.springframework.http.ResponseEntity; import reactor.core.publisher.Mono; import java.util.List; @@ -289,6 +290,15 @@ public Mono> setApplicationPublicToMarketplace(@PathVariab public Mono> setApplicationAsAgencyProfile(@PathVariable String applicationId, @RequestBody ApplicationAsAgencyProfileRequest request); + @Operation( + tags = TAG_APPLICATION_MANAGEMENT, + operationId = "getApplicationManifest", + summary = "Get Application PWA manifest", + description = "Get the PWA manifest for a specific application with its icon and metadata." + ) + @GetMapping("/{applicationId}/manifest.json") + public Mono> getApplicationManifest(@PathVariable String applicationId); + public record BatchAddPermissionRequest(String role, Set userIds, Set groupIds) { } diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/framework/security/SecurityConfig.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/framework/security/SecurityConfig.java index 3c3059710..6e667d00a 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/framework/security/SecurityConfig.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/framework/security/SecurityConfig.java @@ -98,8 +98,14 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, FLOW_URL), // system config ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, CONFIG_URL + "/deploymentId"), // system config ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*"), // application view + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/manifest.json"), // app manifest ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/view"), // application view + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/icons"), // app icons list + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/icons/**"), // app icons ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/view_marketplace"), // application view + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/icons"), + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/icons/**"), + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/manifest.json"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/marketplace-apps"), // marketplace apps ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, USER_URL + "/me"), @@ -133,8 +139,14 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { ServerWebExchangeMatchers.pathMatchers(HttpMethod.HEAD, NewUrl.STATE_URL + "/healthCheck"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.PREFIX + "/status/**"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*"), + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/manifest.json"), // app manifest ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/view"), + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/icons"), // app icons list + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/icons/**"), // app icons ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/view_marketplace"), + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/icons"), + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/icons/**"), + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/manifest.json"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/marketplace-apps"), // marketplace apps ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.USER_URL + "/me"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.USER_URL + "/currentUser"), diff --git a/server/api-service/lowcoder-server/src/main/resources/application-lowcoder-local-dev.yml b/server/api-service/lowcoder-server/src/main/resources/application-lowcoder-local-dev.yml new file mode 100644 index 000000000..d8b0a96a0 --- /dev/null +++ b/server/api-service/lowcoder-server/src/main/resources/application-lowcoder-local-dev.yml @@ -0,0 +1,8 @@ +spring: + data: + mongodb: + authentication-database: admin + auto-index-creation: false + uri: mongodb://localhost:27017/lowcoder?authSource=admin + redis: + url: redis://localhost:6379 diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java index c09bc9d63..370431eb9 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java @@ -14,6 +14,7 @@ import org.lowcoder.domain.application.model.ApplicationStatus; import org.lowcoder.domain.application.service.ApplicationRecordService; import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.application.service.ApplicationService; import org.mockito.Mockito; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -33,6 +34,7 @@ class ApplicationEndpointsTest { private BusinessEventPublisher businessEventPublisher; private GidService gidService; private ApplicationRecordService applicationRecordService; + private ApplicationService applicationService; private ApplicationController controller; private static final String TEST_APPLICATION_ID = "test-app-id"; @@ -47,6 +49,7 @@ void setUp() { businessEventPublisher = Mockito.mock(BusinessEventPublisher.class); gidService = Mockito.mock(GidService.class); applicationRecordService = Mockito.mock(ApplicationRecordService.class); + applicationService = Mockito.mock(ApplicationService.class); // Setup common mocks when(businessEventPublisher.publishApplicationCommonEvent(any(), any(), any())).thenReturn(Mono.empty()); @@ -88,7 +91,8 @@ void setUp() { applicationApiService, businessEventPublisher, gidService, - applicationRecordService + applicationRecordService, + applicationService ); } From ce8ec1a5cb301a6326e97d45421e5191bfb8de24 Mon Sep 17 00:00:00 2001 From: Elier Herrera Date: Sat, 9 Aug 2025 07:34:34 -0400 Subject: [PATCH 13/13] feat(pwa-client): per-app PWA icon handling, maskable icons, editor display, and ApplicationV2 updates; add icon conversion utils --- client/package.json | 184 +++--- .../lowcoder/src/api/subscriptionApi.ts | 574 ++++++++++-------- client/packages/lowcoder/src/app.tsx | 270 ++++---- .../src/comps/comps/multiIconDisplay.tsx | 28 +- .../src/pages/ApplicationV2/index.tsx | 261 ++++---- .../lowcoder/src/pages/editor/editorView.tsx | 253 +++++--- .../lowcoder/src/util/iconConversionUtils.ts | 138 +++++ 7 files changed, 1002 insertions(+), 706 deletions(-) create mode 100644 client/packages/lowcoder/src/util/iconConversionUtils.ts diff --git a/client/package.json b/client/package.json index 32deab248..ab2ded1c7 100644 --- a/client/package.json +++ b/client/package.json @@ -1,94 +1,94 @@ { - "name": "lowcoder-frontend", - "version": "2.7.3", - "type": "module", - "private": true, - "workspaces": [ - "packages/*" - ], - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "scripts": { - "start": "yarn workspace lowcoder start", - "start-win": "LOWCODER_API_SERVICE_URL=http://localhost:3000 yarn start", - "start:ee": "REACT_APP_EDITION=enterprise yarn workspace lowcoder start", - "translate": "node --loader ts-node/esm ./scripts/translate.js", - "build": "yarn node ./scripts/build.js", - "build:ee": "REACT_APP_EDITION=enterprise yarn node ./scripts/build.js", - "test": "jest && yarn workspace lowcoder-comps test", - "prepare": "yarn workspace lowcoder prepare", - "build:core": "yarn workspace lowcoder-core build", - "test:core": "yarn workspace lowcoder-core test", - "lint": "eslint . --fix" - }, - "devDependencies": { - "@babel/preset-env": "^7.20.2", - "@babel/preset-typescript": "^7.18.6", - "@rollup/plugin-typescript": "^12.1.0", - "@testing-library/jest-dom": "^5.16.5", - "@types/file-saver": "^2.0.5", - "@types/jest": "^29.2.2", - "@types/mime": "^2.0.3", - "@types/qrcode.react": "^1.0.2", - "@types/react-grid-layout": "^1.3.0", - "@types/react-helmet": "^6.1.5", - "@types/react-resizable": "^3.0.5", - "@types/react-router-dom": "^5.3.2", - "@types/shelljs": "^0.8.11", - "@types/simplebar": "^5.3.3", - "@types/stylis": "^4.0.2", - "@types/tern": "0.23.4", - "@types/ua-parser-js": "^0.7.36", - "@welldone-software/why-did-you-render": "^6.2.3", - "add": "^2.0.6", - "babel-jest": "^29.3.0", - "babel-preset-react-app": "^10.0.1", - "babel-preset-vite": "^1.1.3", - "husky": "^8.0.1", - "jest": "^29.5.0", - "jest-canvas-mock": "^2.5.2", - "jest-environment-jsdom": "^29.5.0", - "lint-staged": "^13.0.1", - "lowcoder-cli": "workspace:^", - "mq-polyfill": "^1.1.8", - "prettier": "^3.1.0", - "rimraf": "^3.0.2", - "shelljs": "^0.8.5", - "svgo": "^3.0.0", - "ts-node": "^10.4.0", - "typescript": "^4.8.4", - "whatwg-fetch": "^3.6.2" - }, - "lint-staged": { - "**/*.{mjs,ts,tsx,json,md,html}": "prettier --write --ignore-unknown", - "**/*.svg": "svgo" - }, - "packageManager": "yarn@3.6.4", - "resolutions": { - "@types/react": "^18", - "moment": "2.29.2", - "canvas": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.2.1.tgz", - "react-virtualized@^9.22.3": "patch:react-virtualized@npm%3A9.22.3#./.yarn/patches/react-virtualized-npm-9.22.3-0fff3cbf64.patch", - "eslint-plugin-only-ascii@^0.0.0": "patch:eslint-plugin-only-ascii@npm%3A0.0.0#./.yarn/patches/eslint-plugin-only-ascii-npm-0.0.0-29e3417685.patch" - }, - "dependencies": { - "@lottiefiles/react-lottie-player": "^3.5.3", - "@remixicon/react": "^4.1.1", - "@supabase/supabase-js": "^2.45.4", - "@testing-library/react": "^14.1.2", - "@testing-library/user-event": "^14.5.1", - "@types/styled-components": "^5.1.34", - "antd-mobile": "^5.34.0", - "chalk": "4", - "flag-icons": "^7.2.1", - "number-precision": "^1.6.0", - "react-countup": "^6.5.3", - "react-github-btn": "^1.4.0", - "react-player": "^2.11.0", - "resize-observer-polyfill": "^1.5.1", - "rollup": "^4.22.5", - "simplebar": "^6.2.5", - "tui-image-editor": "^3.15.3" - } + "name": "lowcoder-frontend", + "version": "2.7.3", + "type": "module", + "private": true, + "workspaces": [ + "packages/*" + ], + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "scripts": { + "start": "yarn workspace lowcoder start", + "start-win": "LOWCODER_API_SERVICE_URL=http://localhost:8080 LOWCODER_NODE_SERVICE_URL=http://localhost:6060 yarn start", + "start:ee": "REACT_APP_EDITION=enterprise yarn workspace lowcoder start", + "translate": "node --loader ts-node/esm ./scripts/translate.js", + "build": "yarn node ./scripts/build.js", + "build:ee": "REACT_APP_EDITION=enterprise yarn node ./scripts/build.js", + "test": "jest && yarn workspace lowcoder-comps test", + "prepare": "yarn workspace lowcoder prepare", + "build:core": "yarn workspace lowcoder-core build", + "test:core": "yarn workspace lowcoder-core test", + "lint": "eslint . --fix" + }, + "devDependencies": { + "@babel/preset-env": "^7.20.2", + "@babel/preset-typescript": "^7.18.6", + "@rollup/plugin-typescript": "^12.1.0", + "@testing-library/jest-dom": "^5.16.5", + "@types/file-saver": "^2.0.5", + "@types/jest": "^29.2.2", + "@types/mime": "^2.0.3", + "@types/qrcode.react": "^1.0.2", + "@types/react-grid-layout": "^1.3.0", + "@types/react-helmet": "^6.1.5", + "@types/react-resizable": "^3.0.5", + "@types/react-router-dom": "^5.3.2", + "@types/shelljs": "^0.8.11", + "@types/simplebar": "^5.3.3", + "@types/stylis": "^4.0.2", + "@types/tern": "0.23.4", + "@types/ua-parser-js": "^0.7.36", + "@welldone-software/why-did-you-render": "^6.2.3", + "add": "^2.0.6", + "babel-jest": "^29.3.0", + "babel-preset-react-app": "^10.0.1", + "babel-preset-vite": "^1.1.3", + "husky": "^8.0.1", + "jest": "^29.5.0", + "jest-canvas-mock": "^2.5.2", + "jest-environment-jsdom": "^29.5.0", + "lint-staged": "^13.0.1", + "lowcoder-cli": "workspace:^", + "mq-polyfill": "^1.1.8", + "prettier": "^3.1.0", + "rimraf": "^3.0.2", + "shelljs": "^0.8.5", + "svgo": "^3.0.0", + "ts-node": "^10.4.0", + "typescript": "^4.8.4", + "whatwg-fetch": "^3.6.2" + }, + "lint-staged": { + "**/*.{mjs,ts,tsx,json,md,html}": "prettier --write --ignore-unknown", + "**/*.svg": "svgo" + }, + "packageManager": "yarn@3.6.4", + "resolutions": { + "@types/react": "^18", + "moment": "2.29.2", + "canvas": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.2.1.tgz", + "react-virtualized@^9.22.3": "patch:react-virtualized@npm%3A9.22.3#./.yarn/patches/react-virtualized-npm-9.22.3-0fff3cbf64.patch", + "eslint-plugin-only-ascii@^0.0.0": "patch:eslint-plugin-only-ascii@npm%3A0.0.0#./.yarn/patches/eslint-plugin-only-ascii-npm-0.0.0-29e3417685.patch" + }, + "dependencies": { + "@lottiefiles/react-lottie-player": "^3.5.3", + "@remixicon/react": "^4.1.1", + "@supabase/supabase-js": "^2.45.4", + "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.1", + "@types/styled-components": "^5.1.34", + "antd-mobile": "^5.34.0", + "chalk": "4", + "flag-icons": "^7.2.1", + "number-precision": "^1.6.0", + "react-countup": "^6.5.3", + "react-github-btn": "^1.4.0", + "react-player": "^2.11.0", + "resize-observer-polyfill": "^1.5.1", + "rollup": "^4.22.5", + "simplebar": "^6.2.5", + "tui-image-editor": "^3.15.3" + } } diff --git a/client/packages/lowcoder/src/api/subscriptionApi.ts b/client/packages/lowcoder/src/api/subscriptionApi.ts index db4599dc4..9525d2f75 100644 --- a/client/packages/lowcoder/src/api/subscriptionApi.ts +++ b/client/packages/lowcoder/src/api/subscriptionApi.ts @@ -1,303 +1,341 @@ -import Api from "api/api"; -import axios, { AxiosInstance, AxiosRequestConfig, CancelToken } from "axios"; -import { calculateFlowCode } from "./apiUtils"; +import Api from 'api/api' +import axios, { AxiosInstance, AxiosRequestConfig, CancelToken } from 'axios' +import { calculateFlowCode } from './apiUtils' import type { - LowcoderNewCustomer, - LowcoderSearchCustomer, - StripeCustomer, -} from "@lowcoder-ee/constants/subscriptionConstants"; + LowcoderNewCustomer, + LowcoderSearchCustomer, + StripeCustomer, +} from '@lowcoder-ee/constants/subscriptionConstants' export type ResponseType = { - response: any; -}; + response: any +} // Axios Configuration const lcHeaders = { - "Lowcoder-Token": calculateFlowCode(), - "Content-Type": "application/json" -}; + 'Lowcoder-Token': calculateFlowCode(), + 'Content-Type': 'application/json', +} -let axiosIns: AxiosInstance | null = null; +let axiosIns: AxiosInstance | null = null const getAxiosInstance = (clientSecret?: string) => { - if (axiosIns && !clientSecret) { - return axiosIns; - } + if (axiosIns && !clientSecret) { + return axiosIns + } - const headers: Record = { - "Content-Type": "application/json", - }; + const headers: Record = { + 'Content-Type': 'application/json', + } - const apiRequestConfig: AxiosRequestConfig = { - baseURL: "https://api-service.lowcoder.cloud/api/flow", - headers, - }; + const apiRequestConfig: AxiosRequestConfig = { + baseURL: 'https://api-service.lowcoder.cloud/api/flow', + headers, + } - axiosIns = axios.create(apiRequestConfig); - return axiosIns; -}; + axiosIns = axios.create(apiRequestConfig) + return axiosIns +} class SubscriptionApi 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 - }; + 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."); - }, 10000); - - response = await axiosInstance.request({ - ...requestConfig, - cancelToken: retrySource.token, - }); - - clearTimeout(retryTimeoutId); - } catch (retryError) { - console.warn("Error at Secure Flow Request. Retry failed:", retryError); - throw retryError; + 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.') + }, 10000) + + 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 } - } else { - console.warn("Error at Secure Flow Request:", error); - throw error; - } - } finally { - clearTimeout(timeoutId); // Clear the initial timeout - } - return response; - } + return response + } } // API Functions -export const searchCustomer = async (subscriptionCustomer: LowcoderSearchCustomer) => { - const apiBody = { - path: "webhook/secure/search-customer", - data: subscriptionCustomer, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data?.length === 1 ? result.data.data[0] as StripeCustomer : null; - } catch (error) { - console.error("Error searching customer:", error); - throw error; - } -}; +export const searchCustomer = async ( + subscriptionCustomer: LowcoderSearchCustomer +) => { + const apiBody = { + path: 'webhook/secure/search-customer', + data: subscriptionCustomer, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data?.length === 1 + ? (result.data.data[0] as StripeCustomer) + : null + } catch (error) { + console.error('Error searching customer:', error) + throw error + } +} export const searchSubscriptions = async (customerId: string) => { - const apiBody = { - path: "webhook/secure/search-subscriptions", - data: { customerId }, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data ?? []; - } catch (error) { - console.error("Error searching subscriptions:", error); - throw error; - } -}; - -export const searchCustomersSubscriptions = async (Customer: LowcoderSearchCustomer) => { - const apiBody = { - path: "webhook/secure/search-customersubscriptions", - data: Customer, - method: "post", - headers: lcHeaders - }; - - try { - const result = await SubscriptionApi.secureRequest(apiBody); - - if (!result || !result.data) { - return []; + const apiBody = { + path: 'webhook/secure/search-subscriptions', + data: { customerId }, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data ?? [] + } catch (error) { + console.error('Error searching subscriptions:', error) + throw error + } +} + +export const searchCustomersSubscriptions = async ( + Customer: LowcoderSearchCustomer +) => { + const apiBody = { + path: 'webhook/secure/search-customersubscriptions', + data: Customer, + method: 'post', + headers: lcHeaders, + } + + try { + const result = await SubscriptionApi.secureRequest(apiBody) + + if (!result || !result.data) { + return [] + } + + // Ensure result.data is an array before filtering + if (!Array.isArray(result.data)) { + console.warn( + 'searchCustomersSubscriptions: result.data is not an array', + result.data + ) + return [] + } + + // Filter out entries with `"success": "false"` + const validEntries = result.data.filter( + (entry: any) => entry.success !== 'false' + ) + + // Flatten the data arrays and filter out duplicates by `id` + const uniqueSubscriptions = Object.values( + validEntries.reduce((acc: Record, entry: any) => { + entry.data.forEach((subscription: any) => { + if (!acc[subscription.id]) { + acc[subscription.id] = subscription + } + }) + return acc + }, {}) + ) + + return uniqueSubscriptions + } catch (error) { + console.error('Error searching customer:', error) + throw error + } +} + +export const createCustomer = async ( + subscriptionCustomer: LowcoderNewCustomer +) => { + const apiBody = { + path: 'webhook/secure/create-customer', + data: subscriptionCustomer, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody, 15000) + return result?.data as StripeCustomer + } catch (error) { + console.error('Error creating customer:', error) + throw error + } +} + +export const cleanupCustomer = async ( + subscriptionCustomer: LowcoderSearchCustomer +) => { + const apiBody = { + path: 'webhook/secure/cleanup-customer', + data: subscriptionCustomer, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody, 15000) + return result?.data as any + } catch (error) { + console.error('Error creating customer:', error) + throw error } +} - // Filter out entries with `"success": "false"` - const validEntries = result.data?.filter((entry: any) => entry.success !== "false"); - - // Flatten the data arrays and filter out duplicates by `id` - const uniqueSubscriptions = Object.values( - validEntries.reduce((acc: Record, entry: any) => { - entry.data.forEach((subscription: any) => { - if (!acc[subscription.id]) { - acc[subscription.id] = subscription; - } - }); - return acc; - }, {}) - ); - - return uniqueSubscriptions; - } catch (error) { - console.error("Error searching customer:", error); - throw error; - } -}; - -export const createCustomer = async (subscriptionCustomer: LowcoderNewCustomer) => { - const apiBody = { - path: "webhook/secure/create-customer", - data: subscriptionCustomer, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody, 15000); - return result?.data as StripeCustomer; - } catch (error) { - console.error("Error creating customer:", error); - throw error; - } -}; - -export const cleanupCustomer = async (subscriptionCustomer: LowcoderSearchCustomer) => { - const apiBody = { - path: "webhook/secure/cleanup-customer", - data: subscriptionCustomer, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody, 15000); - return result?.data as any; - } catch (error) { - console.error("Error creating customer:", error); - throw error; - } -}; - -export const getProduct = async (productId : string) => { - const apiBody = { - path: "webhook/secure/get-product", - method: "post", - data: {"productId" : productId}, - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data as any; - } catch (error) { - console.error("Error fetching product:", error); - throw error; - } -}; +export const getProduct = async (productId: string) => { + const apiBody = { + path: 'webhook/secure/get-product', + method: 'post', + data: { productId: productId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data as any + } catch (error) { + console.error('Error fetching product:', error) + throw error + } +} export const getProducts = async () => { - const apiBody = { - path: "webhook/secure/get-products", - method: "post", - data: {}, - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data as any[]; - } catch (error) { - console.error("Error fetching product:", error); - throw error; - } -}; - -export const createCheckoutLink = async (customer: StripeCustomer, priceId: string, quantity: number, discount?: number) => { - const domain = window.location.protocol + "//" + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); - - const apiBody = { - path: "webhook/secure/create-checkout-link", - data: { - "customerId": customer.id, - "priceId": priceId, - "quantity": quantity, - "discount": discount, - baseUrl: domain - }, - method: "post", - headers: lcHeaders - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data ? { id: result.data.id, url: result.data.url } : null; - } catch (error) { - console.error("Error creating checkout link:", error); - throw error; - } -}; + const apiBody = { + path: 'webhook/secure/get-products', + method: 'post', + data: {}, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data as any[] + } catch (error) { + console.error('Error fetching product:', error) + throw error + } +} + +export const createCheckoutLink = async ( + customer: StripeCustomer, + priceId: string, + quantity: number, + discount?: number +) => { + const domain = + window.location.protocol + + '//' + + window.location.hostname + + (window.location.port ? ':' + window.location.port : '') + + const apiBody = { + path: 'webhook/secure/create-checkout-link', + data: { + customerId: customer.id, + priceId: priceId, + quantity: quantity, + discount: discount, + baseUrl: domain, + }, + method: 'post', + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data + ? { id: result.data.id, url: result.data.url } + : null + } catch (error) { + console.error('Error creating checkout link:', error) + throw error + } +} // Function to get subscription details from Stripe export const getSubscriptionDetails = async (subscriptionId: string) => { - const apiBody = { - path: "webhook/secure/get-subscription-details", - method: "post", - data: { "subscriptionId": subscriptionId }, - headers: lcHeaders, - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data; - } catch (error) { - console.error("Error fetching subscription details:", error); - throw error; - } -}; + const apiBody = { + path: 'webhook/secure/get-subscription-details', + method: 'post', + data: { subscriptionId: subscriptionId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data + } catch (error) { + console.error('Error fetching subscription details:', error) + throw error + } +} // Function to get invoice documents from Stripe -export const getInvoices = async (subscriptionId: string) => { - const apiBody = { - path: "webhook/secure/get-subscription-invoices", - method: "post", - data: { "subscriptionId": subscriptionId }, - headers: lcHeaders, - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data?.data ?? []; - } catch (error) { - console.error("Error fetching invoices:", error); - throw error; - } -}; +export const getInvoices = async (subscriptionId: string) => { + const apiBody = { + path: 'webhook/secure/get-subscription-invoices', + method: 'post', + data: { subscriptionId: subscriptionId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data?.data ?? [] + } catch (error) { + console.error('Error fetching invoices:', error) + throw error + } +} // Function to get a customer Portal Session from Stripe -export const getCustomerPortalSession = async (customerId: string) => { - const apiBody = { - path: "webhook/secure/create-customer-portal-session", - method: "post", - data: { "customerId": customerId }, - headers: lcHeaders, - }; - try { - const result = await SubscriptionApi.secureRequest(apiBody); - return result?.data; - } catch (error) { - console.error("Error fetching invoices:", error); - throw error; - } -}; - -export default SubscriptionApi; +export const getCustomerPortalSession = async (customerId: string) => { + const apiBody = { + path: 'webhook/secure/create-customer-portal-session', + method: 'post', + data: { customerId: customerId }, + headers: lcHeaders, + } + try { + const result = await SubscriptionApi.secureRequest(apiBody) + return result?.data + } catch (error) { + console.error('Error fetching invoices:', error) + throw error + } +} + +export default SubscriptionApi diff --git a/client/packages/lowcoder/src/app.tsx b/client/packages/lowcoder/src/app.tsx index a4857882e..df2970409 100644 --- a/client/packages/lowcoder/src/app.tsx +++ b/client/packages/lowcoder/src/app.tsx @@ -84,7 +84,7 @@ const Wrapper = React.memo((props: { const deploymentId = useSelector(getDeploymentId); const user = useSelector(getUser); const dispatch = useDispatch(); - + useEffect(() => { if (user.currentOrgId) { dispatch(fetchDeploymentIdAction()); @@ -92,22 +92,21 @@ const Wrapper = React.memo((props: { }, [user.currentOrgId]); useEffect(() => { - if(Boolean(deploymentId)) { + if (Boolean(deploymentId)) { dispatch(fetchSubscriptionsAction()) } }, [deploymentId]); - + const theme = useMemo(() => { return { hashed: false, token: { - fontFamily: `${ - props.fontFamily + fontFamily: `${props.fontFamily ? props.fontFamily.split('+').join(' ') : `-apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, "Segoe UI", "PingFang SC", "Microsoft Yahei", "Hiragino Sans GB", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"` - }, sans-serif`, + }, sans-serif`, }, } }, [props.fontFamily]); @@ -197,7 +196,7 @@ class AppIndex extends React.Component { {{this.props.brandName}} - {} + {/* Favicon is set per-route (admin vs. app). App routes handle their own via editorView.tsx */} { name="apple-mobile-web-app-title" content={this.props.brandName} /> - - + {/* App-specific apple-touch-icon is set within app routes; avoid a global conflicting tag here. */} { ]} - - - - - - - - - - - - - - - - - - - - - - - - - {this.props.isFetchUserFinished && this.props.defaultHomePage? ( - !this.props.orgDev ? ( - - ) : ( - - ) - ) : ( - - )} + + + - + - {developEnv() && ( - <> + + + - - - - )} - - + + + + + + + + + + + + + + {this.props.isFetchUserFinished && this.props.defaultHomePage ? ( + !this.props.orgDev ? ( + + ) : ( + + ) + ) : ( + + )} + + + + {developEnv() && ( + <> + + + + + + )} + + ); } diff --git a/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx b/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx index cccb2a1fc..8b63bfc12 100644 --- a/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx +++ b/client/packages/lowcoder/src/comps/comps/multiIconDisplay.tsx @@ -1,27 +1,33 @@ +import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { findIconDefinition, library } from '@fortawesome/fontawesome-svg-core'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { far } from '@fortawesome/free-regular-svg-icons'; import * as AntdIcons from '@ant-design/icons'; -library.add(far,fas); +library.add(far, fas); + +export function parseIconIdentifier(identifier: string) { + // Handle null, undefined, or non-string values + if (!identifier || typeof identifier !== 'string') { + return { type: 'unknown', name: "" }; + } -function parseIconIdentifier(identifier: string) { if (identifier.startsWith('/icon:antd/')) { let name = identifier.split('/')[2]; return { type: 'antd', name }; - } + } else if (identifier.startsWith('/icon:solid/') || identifier.startsWith('/icon:regular/')) { const [style, name] = identifier.substring(6).split('/'); return { type: 'fontAwesome', style, name }; - } + } else if (identifier.startsWith('data:image')) { return { type: 'base64', data: identifier, name: "" }; - } + } else if (identifier.startsWith('http')) { return { type: 'url', url: identifier, name: "" }; - } + } else { return { type: 'unknown', name: "" }; } @@ -52,7 +58,7 @@ const appendStyleSuffix = (name: string) => { // Multi icon Display Component const baseMultiIconDisplay: React.FC = ({ identifier, width = '24px', height = '24px', style }) => { - + const iconData = parseIconIdentifier(identifier); if (iconData.type === 'fontAwesome') { @@ -65,21 +71,21 @@ const baseMultiIconDisplay: React.FC = ({ identifier, width = '24px', return null; } return ; - } + } else if (iconData.type === 'antd') { let iconName = convertToCamelCase(iconData.name); iconName = appendStyleSuffix(iconName); - iconName = iconName.charAt(0).toUpperCase() + iconName.slice(1); + iconName = iconName.charAt(0).toUpperCase() + iconName.slice(1); const AntdIcon = (AntdIcons as any)[iconName]; if (!AntdIcon) { console.error(`ANTd Icon ${iconData.name} not found`); return null; } return ; - } + } else if (iconData.type === 'url' || iconData.type === 'base64') { return icon; - } + } else { return null; // Unknown type } diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx index 20ba7f5ab..c98e687f2 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx @@ -32,7 +32,10 @@ import { UserIcon, } from "lowcoder-design"; import React, { useCallback, useEffect, useState, useMemo } from "react"; +import { Helmet } from "react-helmet"; import { fetchHomeData } from "redux/reduxActions/applicationActions"; +import { getBrandingConfig } from "redux/selectors/configSelectors"; +import { buildMaterialPreviewURL } from "util/materialUtils"; import { fetchSubscriptionsAction } from "redux/reduxActions/subscriptionActions"; import { getHomeOrg, normalAppListSelector } from "redux/selectors/applicationSelector"; import { DatasourceHome } from "../datasource"; @@ -61,11 +64,12 @@ import { SubscriptionProductsEnum } from '@lowcoder-ee/constants/subscriptionCon import { EnterpriseProvider } from "@lowcoder-ee/util/context/EnterpriseContext"; import { SimpleSubscriptionContextProvider } from "@lowcoder-ee/util/context/SimpleSubscriptionContext"; import { selectIsLicenseActive } from "redux/selectors/enterpriseSelectors"; +// (deduped imports removed) // adding App Editor, so we can show Apps inside the Admin Area import AppEditor from "../editor/AppEditor"; -import {LoadingBarHideTrigger} from "@lowcoder-ee/util/hideLoading"; +import { LoadingBarHideTrigger } from "@lowcoder-ee/util/hideLoading"; const TabLabel = styled.div` font-weight: 500; @@ -90,6 +94,7 @@ export default function ApplicationHome() { const allFolders = useSelector(foldersSelector); const user = useSelector(getUser); const org = useSelector(getHomeOrg); + const brandingConfig = useSelector(getBrandingConfig); const allAppCount = allApplications.length; const allFoldersCount = allFolders.length; const orgHomeId = "root"; @@ -130,151 +135,159 @@ export default function ApplicationHome() { return ( + + {/* Default favicon for admin/non-app routes */} + + {/* */} - {/* */} - */} + {trans("home.profile")}, - routePath: USER_PROFILE_URL, - routeComp: UserProfileView, - icon: ({ selected, ...otherProps }) => selected ? : , - }, - { - text: {trans("home.news")}, - routePath: NEWS_URL, - routeComp: NewsView, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - style: { color: "red" }, - }, - { - text: {trans("home.orgHome")}, - routePath: ORG_HOME_URL, - routePathExact: false, - routeComp: OrgView, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => !user.orgDev, - }, - { - text: {trans("home.marketplace")}, - routePath: MARKETPLACE_URL, - routePathExact: false, - routeComp: MarketplaceView, - icon: ({ selected, ...otherProps }) => selected ? : , - }, - ] + text: {trans("home.profile")}, + routePath: USER_PROFILE_URL, + routeComp: UserProfileView, + icon: ({ selected, ...otherProps }) => selected ? : , }, - { - items: [ - { - text: {trans("home.allApplications")}, - routePath: ALL_APPLICATIONS_URL, - routeComp: HomeView, - icon: ({ selected, ...otherProps }) => selected ? : , - onSelected: (_, currentPath) => currentPath === ALL_APPLICATIONS_URL || currentPath.startsWith("/folder"), - }, - ], + text: {trans("home.news")}, + routePath: NEWS_URL, + routeComp: NewsView, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + style: { color: "red" }, }, - { - items: [ - { - text: {trans("home.datasource")}, - routePath: DATASOURCE_URL, - routePathExact: false, - routeComp: DatasourceHome, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - onSelected: (_, currentPath) => currentPath.split("/")[1] === "datasource", - mobileVisible: false, - }, - { - text: {trans("home.queryLibrary")}, - routePath: QUERY_LIBRARY_URL, - routeComp: QueryLibraryEditor, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - mobileVisible: false, - } - ], + text: {trans("home.orgHome")}, + routePath: ORG_HOME_URL, + routePathExact: false, + routeComp: OrgView, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => !user.orgDev, }, - - // Show Subscription if not yet subscribed else Support Pages - { - items: [ - { - text: {trans("home.support")}, - routePath: supportSubscription ? SUPPORT_URL : SUBSCRIPTION_SETTING, - routeComp: supportSubscription ? Support : Setting, - routePathExact: false, - icon: ({ selected, ...otherProps }) => , - mobileVisible: true, - visible: ({ user }) => user.orgDev - } - ] + text: {trans("home.marketplace")}, + routePath: MARKETPLACE_URL, + routePathExact: false, + routeComp: MarketplaceView, + icon: ({ selected, ...otherProps }) => selected ? : , }, + ] + }, + + { + items: [ { - items: [ - { - text: {trans("environments.detail_enterpriseEdition")}, - routePath: ENVIRONMENT_SETTING, - routeComp: Setting, - routePathExact: false, - icon: ({ selected, ...otherProps }) => , - mobileVisible: true, - visible: () => !isLicenseActive, - style: { color: "#ff6f3c" }, - } - ] + text: {trans("home.allApplications")}, + routePath: ALL_APPLICATIONS_URL, + routeComp: HomeView, + icon: ({ selected, ...otherProps }) => selected ? : , + onSelected: (_, currentPath) => currentPath === ALL_APPLICATIONS_URL || currentPath.startsWith("/folder"), }, + ], + }, + { + items: [ { - items: [ - { - text: {trans("settings.title")}, - routePath: SETTING_URL, - routePathExact: false, - routeComp: Setting, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - onSelected: (_, currentPath) => currentPath.split("/")[1] === "setting", - } - ] + text: {trans("home.datasource")}, + routePath: DATASOURCE_URL, + routePathExact: false, + routeComp: DatasourceHome, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + onSelected: (_, currentPath) => currentPath.split("/")[1] === "datasource", + mobileVisible: false, }, + { + text: {trans("home.queryLibrary")}, + routePath: QUERY_LIBRARY_URL, + routeComp: QueryLibraryEditor, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + mobileVisible: false, + } + ], + }, + + // Show Subscription if not yet subscribed else Support Pages + + { + items: [ + { + text: {trans("home.support")}, + routePath: supportSubscription ? SUPPORT_URL : SUBSCRIPTION_SETTING, + routeComp: supportSubscription ? Support : Setting, + routePathExact: false, + icon: ({ selected, ...otherProps }) => , + mobileVisible: true, + visible: ({ user }) => user.orgDev + } + ] + }, + { + items: [ + { + text: {trans("environments.detail_enterpriseEdition")}, + routePath: ENVIRONMENT_SETTING, + routeComp: Setting, + routePathExact: false, + icon: ({ selected, ...otherProps }) => , + mobileVisible: true, + visible: () => !isLicenseActive, + style: { color: "#ff6f3c" }, + } + ] + }, + + { + items: [ + { + text: {trans("settings.title")}, + routePath: SETTING_URL, + routePathExact: false, + routeComp: Setting, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, + onSelected: (_, currentPath) => currentPath.split("/")[1] === "setting", + } + ] + }, + { + items: [ { - items: [ - { - text: {trans("home.trash")}, - routePath: TRASH_URL, - routeComp: TrashView, - icon: ({ selected, ...otherProps }) => selected ? : , - visible: ({ user }) => user.orgDev, - }, - ], + text: {trans("home.trash")}, + routePath: TRASH_URL, + routeComp: TrashView, + icon: ({ selected, ...otherProps }) => selected ? : , + visible: ({ user }) => user.orgDev, }, + ], + }, - // this we need to show the Folders view in the Admin Area + // this we need to show the Folders view in the Admin Area + { + items: [ { - items: [ - { - text: "", - routePath: FOLDER_URL, - routeComp: FolderView, - visible: () => false, - } - ] + text: "", + routePath: FOLDER_URL, + routeComp: FolderView, + visible: () => false, } + ] + } - ]} - /> - {/* */} + ]} + /> + {/* */} {/* */} diff --git a/client/packages/lowcoder/src/pages/editor/editorView.tsx b/client/packages/lowcoder/src/pages/editor/editorView.tsx index c722f907f..69f31ca9c 100644 --- a/client/packages/lowcoder/src/pages/editor/editorView.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorView.tsx @@ -1,6 +1,6 @@ import { default as Divider } from "antd/es/divider"; import { default as Menu } from "antd/es/menu"; -import { default as Sider} from "antd/es/layout/Sider"; +import { default as Sider } from "antd/es/layout/Sider"; import { PreloadComp } from "comps/comps/preLoadComp"; import UIComp from "comps/comps/uiComp"; import { EditorContext } from "comps/editorState"; @@ -63,73 +63,75 @@ import { import { isEqual, noop } from "lodash"; import { AppSettingContext, AppSettingType } from "@lowcoder-ee/comps/utils/appSettingContext"; import { getBrandingSetting } from "@lowcoder-ee/redux/selectors/enterpriseSelectors"; +import { getBrandingConfig } from "redux/selectors/configSelectors"; import Flex from "antd/es/flex"; +import { getAppFavicon, getOgImageUrl } from "util/iconConversionUtils"; // import { BottomSkeleton } from "./bottom/BottomContent"; const Header = lazy( - () => import("pages/common/header") - .then(module => ({default: module.default})) + () => import("pages/common/header") + .then(module => ({ default: module.default })) ); const BottomSkeleton = lazy( - () => import("pages/editor/bottom/BottomContent") - .then(module => ({default: module.BottomSkeleton})) + () => import("pages/editor/bottom/BottomContent") + .then(module => ({ default: module.BottomSkeleton })) ); const LeftContent = lazy( () => import('./LeftContent') - .then(module => ({default: module.LeftContent})) + .then(module => ({ default: module.LeftContent })) ); const LeftLayersContent = lazy( () => import('./LeftLayersContent') - .then(module => ({default: module.LeftLayersContent})) + .then(module => ({ default: module.LeftLayersContent })) ); const RightPanel = lazy(() => import('pages/editor/right/RightPanel')); const EditorTutorials = lazy(() => import('pages/tutorials/editorTutorials')); const Bottom = lazy(() => import('./bottom/BottomPanel')); const CustomShortcutWrapper = lazy( () => import('pages/editor/editorHotKeys') - .then(module => ({default: module.CustomShortcutWrapper})) + .then(module => ({ default: module.CustomShortcutWrapper })) ); const EditorGlobalHotKeys = lazy( () => import('pages/editor/editorHotKeys') - .then(module => ({default: module.EditorGlobalHotKeys})) + .then(module => ({ default: module.EditorGlobalHotKeys })) ); const EditorHotKeys = lazy( () => import('pages/editor/editorHotKeys') - .then(module => ({default: module.EditorHotKeys})) + .then(module => ({ default: module.EditorHotKeys })) ); const Body = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.Body})) + .then(module => ({ default: module.Body })) ); const EditorContainer = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.EditorContainer})) + .then(module => ({ default: module.EditorContainer })) ); const EditorContainerWithViewMode = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.EditorContainerWithViewMode})) + .then(module => ({ default: module.EditorContainerWithViewMode })) ); const Height100Div = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.Height100Div})) + .then(module => ({ default: module.Height100Div })) ); const LeftPanel = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.LeftPanel})) + .then(module => ({ default: module.LeftPanel })) ); const MiddlePanel = lazy( () => import('pages/common/styledComponent') - .then(module => ({default: module.MiddlePanel})) + .then(module => ({ default: module.MiddlePanel })) ); const HelpDropdown = lazy( () => import('pages/common/help') - .then(module => ({default: module.HelpDropdown})) + .then(module => ({ default: module.HelpDropdown })) ); const PreviewHeader = lazy( () => import('pages/common/previewHeader') - .then(module => ({default: module.PreviewHeader})) + .then(module => ({ default: module.PreviewHeader })) ); const HookCompContainer = styled.div` @@ -144,9 +146,8 @@ const HookCompContainer = styled.div` `; const ViewBody = styled.div<{ $hideBodyHeader?: boolean; $height?: number }>` - height: ${(props) => `calc(${ - props.$height ? props.$height + "px" : "100vh" - } - env(safe-area-inset-bottom) - + height: ${(props) => `calc(${props.$height ? props.$height + "px" : "100vh" + } - env(safe-area-inset-bottom) - ${props.$hideBodyHeader ? "0px" : TopHeaderHeight} )`}; `; @@ -414,6 +415,7 @@ function EditorView(props: EditorViewProps) { const showNewUserGuide = locationState?.showNewUserGuide; const showAppSnapshot = useSelector(showAppSnapshotSelector); const brandingSettings = useSelector(getBrandingSetting); + const brandingConfig = useSelector(getBrandingConfig); const [showShortcutList, setShowShortcutList] = useState(false); const toggleShortcutList = useCallback( () => setShowShortcutList(!showShortcutList), @@ -515,7 +517,7 @@ function EditorView(props: EditorViewProps) { ); } - + return uiComp.getView(); }, [ showAppSnapshot, @@ -530,10 +532,10 @@ function EditorView(props: EditorViewProps) { return ( editorState.deviceType === "mobile" || editorState.deviceType === "tablet" ? ( - {uiComp.getView()} + deviceType={editorState.deviceType} + deviceOrientation={editorState.deviceOrientation} + > + {uiComp.getView()} ) : (
@@ -565,6 +567,26 @@ function EditorView(props: EditorViewProps) { if (readOnly && hideHeader) { return ( + + {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} + {(() => { + const appId = application?.applicationId; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const appIcon512 = appId ? `/api/applications/${appId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` : undefined; + const appIcon192 = appId ? `/api/applications/${appId}/icons/192.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` : undefined; + const manifestHref = appId ? `/api/applications/${appId}/manifest.json` : undefined; + const themeColor = brandBg || '#b480de'; + return [ + manifestHref && , + appIcon192 && , + appIcon512 && , + appIcon512 && , + appIcon512 && , + appIcon512 && , + , + ]; + })()} + {uiComp.getView()}
{hookCompViews}
@@ -575,23 +597,70 @@ function EditorView(props: EditorViewProps) { return ( - {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} - {isLowCoderDomain || isLocalhost && [ - // Adding Support for iframely to be able to embedd apps as iframes - application?.name ? ([ - , - , - ]) : ([ - , - , - ]), - , - , - , - , - // adding Hubspot Support for Analytics - - ]} + {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} + {application && (() => { + const appFavicon = getAppFavicon(appSettingsComp, application.applicationId); + if (appFavicon) { + const bg = brandingSettings?.config_set?.mainBrandingColor; + const href = bg ? `${appFavicon}?bg=${encodeURIComponent(bg)}` : appFavicon; + return ; + } else { + // Render default favicon only when no app-specific favicon is available + const defaultFavicon = brandingConfig?.favicon || "/src/assets/images/favicon.ico"; + return ; + } + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const appleTouchIcon: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const startupImage: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && ( + + )} + {application && ( + + )} + {application && ( + + )} + {application && (() => { + const og = getOgImageUrl(application.applicationId, brandingSettings?.config_set?.mainBrandingColor); + return [ + , + , + ]; + })()} + + + + + + + }> {!hideBodyHeader && } @@ -607,7 +676,7 @@ function EditorView(props: EditorViewProps) { ); } - + // history mode, display with the right panel, a little trick const showRight = panelStatus.right || showAppSnapshot; @@ -623,32 +692,74 @@ function EditorView(props: EditorViewProps) { return ( <> - - {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} - {isLowCoderDomain || isLocalhost && [ - // Adding Support for iframely to be able to embedd apps as iframes - application?.name ? ([ - , - , - ]) : ([ - , - , - ]), - , - , - , - , - // adding Clearbit Support for Analytics + + {application && {appSettingsComp?.children?.title?.getView?.() || application?.name}} + {application && (() => { + const appFavicon = getAppFavicon(appSettingsComp, application.applicationId); + if (appFavicon) { + const bg = brandingSettings?.config_set?.mainBrandingColor; + const href = bg ? `${appFavicon}?bg=${encodeURIComponent(bg)}` : appFavicon; + return ; + } else { + const defaultFavicon = brandingConfig?.favicon || "/src/assets/images/favicon.ico"; + return ; + } + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const appleTouchIcon: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && (() => { + const appIconView = appSettingsComp?.children?.icon?.getView?.(); + const hasAppIcon = Boolean(appIconView); + const brandLogoUrl = brandingConfig?.logo && typeof brandingConfig.logo === 'string' ? brandingConfig.logo : undefined; + const brandBg = brandingSettings?.config_set?.mainBrandingColor; + const startupImage: string = hasAppIcon + ? `/api/applications/${application.applicationId}/icons/512.png${brandBg ? `?bg=${encodeURIComponent(brandBg)}` : ''}` + : (brandLogoUrl || "/android-chrome-512x512.png"); + return ; + })()} + {application && ( + + )} + {application && ( + + )} + {application && ( + + )} + {application && (() => { + const og = getOgImageUrl(application.applicationId, brandingSettings?.config_set?.mainBrandingColor); + return [ + , + , + ]; + })()} + + + + + + - ]} - - { - // log.debug("layout: onDragEnd. Height100Div"); - editorState.setDragging(false); - draggingUtils.clearData(); - } } - > + + { + // log.debug("layout: onDragEnd. Height100Div"); + editorState.setDragging(false); + draggingUtils.clearData(); + }} + > {isPublicApp ? : ( @@ -704,7 +815,7 @@ function EditorView(props: EditorViewProps) { {panelStatus.left && editorModeStatus !== "layout" && ( {menuKey === SiderKey.State && } - + <> {menuKey === SiderKey.Setting && ( @@ -748,7 +859,7 @@ function EditorView(props: EditorViewProps) { {trans("leftPanel.toolbarPreload")} - + {props.preloadComp.getJSLibraryPropertyView()} )} diff --git a/client/packages/lowcoder/src/util/iconConversionUtils.ts b/client/packages/lowcoder/src/util/iconConversionUtils.ts new file mode 100644 index 000000000..522ed6bd6 --- /dev/null +++ b/client/packages/lowcoder/src/util/iconConversionUtils.ts @@ -0,0 +1,138 @@ +import { parseIconIdentifier } from 'comps/comps/multiIconDisplay' + +/** + * Utility functions for handling app-specific favicon and icon conversion + */ + +export interface AppIconInfo { + type: 'antd' | 'fontAwesome' | 'base64' | 'url' | 'unknown' + identifier: string + name?: string + url?: string + data?: string +} + +/** + * Extract app icon information from app settings + */ +export function getAppIconInfo(appSettingsComp: any): AppIconInfo | null { + if (!appSettingsComp?.children?.icon?.getView) { + return null + } + + const iconIdentifier = appSettingsComp.children.icon.getView() + + if (!iconIdentifier) { + return null + } + + // If the identifier is an object, try to extract the string value + let iconString = iconIdentifier + if (typeof iconIdentifier === 'object') { + // Check if it's a React element + if (iconIdentifier.$$typeof === Symbol.for('react.element')) { + // Try to extract icon information from React element props + if (iconIdentifier.props && iconIdentifier.props.value) { + // For URL-based icons, the value contains the URL + iconString = iconIdentifier.props.value + } else if (iconIdentifier.props && iconIdentifier.props.icon) { + iconString = iconIdentifier.props.icon + } else if (iconIdentifier.props && iconIdentifier.props.type) { + // For Ant Design icons, the type might be in props.type + iconString = iconIdentifier.props.type + } else { + return null + } + } else { + // Try to get the string value from the object + if (iconIdentifier.value !== undefined) { + iconString = iconIdentifier.value + } else if (iconIdentifier.toString) { + iconString = iconIdentifier.toString() + } else { + return null + } + } + } + + const parsed = parseIconIdentifier(iconString) + + return { + type: parsed.type as AppIconInfo['type'], + identifier: iconString, + name: parsed.name, + url: parsed.url, + data: parsed.data, + } +} + +/** + * Generate favicon URL for an app + * This is a simple implementation that returns the icon as-is for now + * In Phase 2, this will be replaced with actual icon conversion logic + */ +export function getAppFaviconUrl(appId: string, iconInfo: AppIconInfo): string { + // Use backend PNG conversion endpoint for consistent, cacheable favicons + // The backend handles data URLs/HTTP images and falls back gracefully + return `/api/applications/${appId}/icons/192.png` +} + +/** + * Check if an icon can be used as a favicon + */ +export function canUseAsFavicon(iconInfo: AppIconInfo): boolean { + switch (iconInfo.type) { + case 'url': + case 'base64': + return true + case 'antd': + case 'fontAwesome': + // These need conversion to be used as favicon + return false + default: + return false + } +} + +/** + * Get the appropriate favicon for an app + * Returns the app-specific favicon if available, otherwise null + */ +export function getAppFavicon( + appSettingsComp: any, + appId: string +): string | null { + const iconInfo = getAppIconInfo(appSettingsComp) + + if (!iconInfo) { + return null + } + + // Always prefer the backend-rendered PNG for a reliable favicon + return getAppFaviconUrl(appId, iconInfo) +} + +/** + * Build the backend PNG icon URL for a given size and optional background color. + * Pass backgroundHex with or without leading '#'. + */ +export function getAppIconPngUrl( + appId: string, + size: number, + backgroundHex?: string +): string { + const base = `/api/applications/${appId}/icons/${size}.png` + if (!backgroundHex) return base + const clean = backgroundHex.startsWith('#') + ? backgroundHex + : `#${backgroundHex}` + const bg = encodeURIComponent(clean) + return `${base}?bg=${bg}` +} + +/** + * Convenience URL for share previews (Open Graph / Twitter), using 512 size. + */ +export function getOgImageUrl(appId: string, backgroundHex?: string): string { + return getAppIconPngUrl(appId, 512, backgroundHex) +}