From 465c3f10ca086606ea58755f12aa8c142091446e Mon Sep 17 00:00:00 2001 From: FalkWolsky Date: Mon, 12 Oct 2026 21:19:46 +0200 Subject: [PATCH] SDK Website, Refresh of Admin Area, Previews in Marketplace --- .../lowcoder-design/src/components/button.tsx | 1 + .../lowcoder/marketplace-preview.html | 30 + .../lowcoder/src/assets/fonts/UFL.txt | 96 +++ .../src/assets/fonts/ubuntu-400.woff2 | Bin 0 -> 14088 bytes .../src/assets/fonts/ubuntu-500.woff2 | Bin 0 -> 14264 bytes .../src/assets/fonts/ubuntu-700.woff2 | Bin 0 -> 14920 bytes .../components/layout/AdminListFeedback.tsx | 7 + .../src/components/layout/AdminPagination.tsx | 26 + .../lowcoder/src/components/layout/Layout.tsx | 40 +- .../src/components/layout/SideBarItem.tsx | 26 +- .../src/components/layout/SubSideBar.tsx | 90 ++- .../src/components/layout/adminTheme.tsx | 142 ++++ .../layout/useAdminPagination.test.ts | 81 ++ .../components/layout/useAdminPagination.ts | 70 ++ .../packages/lowcoder/src/i18n/locales/de.ts | 71 +- .../packages/lowcoder/src/i18n/locales/en.ts | 69 ++ .../packages/lowcoder/src/i18n/locales/es.ts | 69 ++ .../packages/lowcoder/src/i18n/locales/it.ts | 71 +- .../packages/lowcoder/src/i18n/locales/pt.ts | 71 +- .../packages/lowcoder/src/i18n/locales/ru.ts | 71 +- .../packages/lowcoder/src/i18n/locales/zh.ts | 75 +- .../lowcoder/src/marketplace-preview.tsx | 131 +++ .../pages/ApplicationV2/CreateDropdown.tsx | 12 +- .../pages/ApplicationV2/FolderNavigator.tsx | 34 + .../src/pages/ApplicationV2/FolderView.tsx | 77 +- .../src/pages/ApplicationV2/HomeCardView.tsx | 14 +- .../src/pages/ApplicationV2/HomeLayout.tsx | 375 +++------ .../src/pages/ApplicationV2/HomeResCard.tsx | 65 +- .../src/pages/ApplicationV2/HomeTableView.tsx | 45 +- .../src/pages/ApplicationV2/HomeView.tsx | 72 +- .../ApplicationV2/MarketplaceResCard.tsx | 47 +- .../pages/ApplicationV2/MarketplaceView.tsx | 750 ++++++++++++++++-- .../src/pages/ApplicationV2/NewsLayout.tsx | 116 ++- .../pages/ApplicationV2/TrashTableView.tsx | 18 +- .../src/pages/ApplicationV2/TrashView.tsx | 68 +- .../pages/ApplicationV2/UserProfileLayout.tsx | 69 +- .../src/pages/ApplicationV2/index.tsx | 20 +- .../marketplace/MarketplacePreview.tsx | 95 +++ .../marketplace/marketplaceStyles.tsx | 391 +++++++++ .../marketplace/marketplaceUtils.test.ts | 88 ++ .../marketplace/marketplaceUtils.ts | 102 +++ .../lowcoder/src/pages/common/header.tsx | 8 +- .../src/pages/datasource/datasourceList.tsx | 110 +-- .../src/pages/queryLibrary/LeftNav.tsx | 47 +- .../pages/queryLibrary/QueryLibraryEditor.tsx | 82 +- .../src/pages/setting/environments/index.tsx | 6 +- .../src/pages/setting/settingHome.tsx | 16 +- .../lowcoder/src/pages/setting/styled.tsx | 30 +- .../setting/subscriptions/productCard.tsx | 23 +- .../subscriptions/subscriptionSetting.tsx | 19 +- client/packages/lowcoder/vite.config.mts | 11 +- deploy/docker/Dockerfile | 12 + deploy/docker/default-multi.env | 2 + deploy/docker/default.env | 3 + .../ApplicationRecordRepository.java | 2 + .../marketplace/MarketplacePreview.java | 22 + .../MarketplacePreviewController.java | 55 ++ .../marketplace/MarketplacePreviewStore.java | 86 ++ .../marketplace/MarketplacePreviewWorker.java | 141 ++++ .../marketplace/MarketplaceRenderClient.java | 47 ++ .../framework/security/SecurityConfig.java | 5 + .../MarketplacePreviewMongoTest.java | 113 +++ .../marketplace/MarketplacePreviewTest.java | 120 +++ .../MarketplacePreviewSecurityTest.java | 73 ++ server/node-service/MARKETPLACE_PREVIEWS.md | 38 + server/node-service/package.json | 9 +- .../scripts/marketplace-preview-dev.cjs | 9 + server/node-service/src/server.ts | 2 + .../src/services/marketplacePreview.js | 241 ++++++ .../test/marketplacePreview.test.cjs | 143 ++++ server/node-service/yarn.lock | 21 + 71 files changed, 4276 insertions(+), 915 deletions(-) create mode 100644 client/packages/lowcoder/marketplace-preview.html create mode 100644 client/packages/lowcoder/src/assets/fonts/UFL.txt create mode 100644 client/packages/lowcoder/src/assets/fonts/ubuntu-400.woff2 create mode 100644 client/packages/lowcoder/src/assets/fonts/ubuntu-500.woff2 create mode 100644 client/packages/lowcoder/src/assets/fonts/ubuntu-700.woff2 create mode 100644 client/packages/lowcoder/src/components/layout/AdminListFeedback.tsx create mode 100644 client/packages/lowcoder/src/components/layout/AdminPagination.tsx create mode 100644 client/packages/lowcoder/src/components/layout/adminTheme.tsx create mode 100644 client/packages/lowcoder/src/components/layout/useAdminPagination.test.ts create mode 100644 client/packages/lowcoder/src/components/layout/useAdminPagination.ts create mode 100644 client/packages/lowcoder/src/marketplace-preview.tsx create mode 100644 client/packages/lowcoder/src/pages/ApplicationV2/FolderNavigator.tsx create mode 100644 client/packages/lowcoder/src/pages/ApplicationV2/marketplace/MarketplacePreview.tsx create mode 100644 client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceStyles.tsx create mode 100644 client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.test.ts create mode 100644 client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.ts create mode 100644 server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreview.java create mode 100644 server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewController.java create mode 100644 server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewStore.java create mode 100644 server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewWorker.java create mode 100644 server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplaceRenderClient.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewMongoTest.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewTest.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/security/MarketplacePreviewSecurityTest.java create mode 100644 server/node-service/MARKETPLACE_PREVIEWS.md create mode 100644 server/node-service/scripts/marketplace-preview-dev.cjs create mode 100644 server/node-service/src/services/marketplacePreview.js create mode 100644 server/node-service/test/marketplacePreview.test.cjs diff --git a/client/packages/lowcoder-design/src/components/button.tsx b/client/packages/lowcoder-design/src/components/button.tsx index be045d7d7d..469c79f84b 100644 --- a/client/packages/lowcoder-design/src/components/button.tsx +++ b/client/packages/lowcoder-design/src/components/button.tsx @@ -198,6 +198,7 @@ const TacoButton = forwardRef( return ( + + + + + + App layout preview + + + + +
+ + + diff --git a/client/packages/lowcoder/src/assets/fonts/UFL.txt b/client/packages/lowcoder/src/assets/fonts/UFL.txt new file mode 100644 index 0000000000..ae78a8f94e --- /dev/null +++ b/client/packages/lowcoder/src/assets/fonts/UFL.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/client/packages/lowcoder/src/assets/fonts/ubuntu-400.woff2 b/client/packages/lowcoder/src/assets/fonts/ubuntu-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2a81dd95f8bfd838180f3245b40ab6b76164e244 GIT binary patch literal 14088 zcmV+jH}}YQPew8T0RR9105=E#4FCWD0Gb2<05-A!0RR9100000000000000000000 z0000R_+A{6G6rA(fp`%R3W14W?`#W%EC2yE0we>7R0JRef_4XqSR4O|71M1#0^I?w z3u#@}*@zf6&OkQ!hd442Y#ac+pC6e0|2erahWMb>_YW}zG1;g#dRS(S*=t?1ac@@; zCRT-tWtiOia#dL9A$7DK5#wRvFaN0jrj@U>4tNgOY(f}JXYq>?9F+K8IOpiNRZ1x7 zvn(Y`tm7Hr4l81G<@u*^{Quf#E}qOUIjOsShXQsffzfQ_4DoaNmjnq(i0Vt6kN|N4 z0U}CF(4bbTR9NY#nEbBos=n&4POR;`#-uu}3We1wsqB)zr5Gzv>uD1Obn#1(JxzaD$4*V}CY5Cgh;nME0fVTh&CgbJ6 zNi`qaTX)p{{J@hYv=iSjwL7m$Sl1sSPqIW>Y_S5a zp6(lIH1qrnBhy#`8G_ z*V`hu#jo0JdLWd+2}C12Acn>B|6T0(bY**=0>j0fv5odDSr8@*mO$VoIR+m5*DkK+ z$p=d~pjwrao23IejIfoO{OOzKL5p`q!$HnvvB-64c6@Drv$nUdDSNI%=gJ%_kKjrW zw~5D*{|5`rsMjKs_= zRgD@5b?T(3s8JKNX3daVwMx^jU4{-FP&#$W(4`AXk72Mz7=diSfHd>XNA{y1nW2iv z!JpT^y-p2=B^3%9MkZWooX9O!sF+!ey8MyNTD9xUKxOHG`Q=|Ct zlWasbL{8(0(-npohSP8+#^6jD2i!0o!!ZJ<;8dJ}Gck%IS#cyEjKOG(NStX&F)#0NCXJpX^sRqV{-xv9@_3%6gUFY}2S5>~jc)s|;>7aVKp@kP1jw3@R;8 z^vV6q?c$7aNPUkZ88#)M&MK%0^`5HY8j!jPddspoEA0+33Wb6bh#uW3gm zFpnZIK3#uxvi)$nJbQj{7ScW>%B&8Z#&zcB25DO?u25p41f~74#Oa^xgA5~Mbsq^< z>%nENIOMO`nk(-*AC1=N*iry}ppISna}K7+`;$eoK38wpXx8Cq?qT&?!7O{f0n{86Vn8FQ}IRY-;t8&1aqER_1m&{djzjJ@V)uvXYAX$v!bURwAuF{(2y%X`M7 z`KsHL9;6s+t4{9f8s`Yu{-znmukEX+@D~!-H1qY$b-8^e9ne?LK_*+XT=;o~3CdnE zr+#f*4ES@#o(Zn{9wv1L-Vr)#Ft^s^i4T2qAJj*?il7%~qtsnVWo=9;+a)LTOL=`@ zKPSf%G@Hj2l{u=_b4_yEU>;@3?|M=>#kJWrr}fD#6@eFb6aa)va&lFxtniRXbl^ znXr^zao7}6LU(nVy?_?}&Wf<#Q8CuvrTy1Wu-?3}fhp}`wKLnu_Xasc_)bc)lL*=>$GBd6 zCuK2H2iihgTtwcMN_<6gOd@Qfx$;Ys%cf497i;|PeC6PMah&%=$L>;@sdSee|2@+Q z88q&0(-uQ5Robv}Q=E4~C0N+fXzA%+w(n3NKF zH~_~1#!G>x1k9I;CymvHGX_6>CPHGcVX<>?Vsmk0@+hU^#o@!{myb-1f{KPNj}|Zz z01HI$2?#}r#KajSgo%ksNXcZ8(}BsBBbNdk3a}Z#XlOjZ7#u{TD-#%mqI);E1i-Od zJVr4YQfJa`2rLfS&Fv+6up2O!`ufual!0dR3FSD)!h_v_WniX4d4ljHC9x$Z8n}d} z8pz71NP!ZT_VUB!h-qd@W5R$Unb0H+hNR0R7&tYcrx&k6Fg#^w- z5Q<2OVuEBQ8EObh9YJg0?AA7Mnn6ff30^zF>g3pF*NBM{As9`l`UulF!ZJYc<`aU& zgk~Q>*k6#EXNMEU4!2A@ys&nlaCZ3N?F13*gc0pTk?h2g4@NTB&L1Zfn(=L?pflu4tmLY>-5)*_j}>=`ZoEPoq1&=~j}F(z(M9!D_V5N@7$Ec<6sr@AIka zNCOY>2KZwsea0E6y+fuQM>QEqA-sG(difQ*&@>s^_#8{?_z(pABnqCVGh}Y+N9>ST zk%Y75+TTiPf8W!&sT7vQU=jfelErw?9zb3#$GDtw2>X@v1ss>cjbpFPm(;h~s}4?n ze8)-rd`y^^obdtHYFtzW%~wYHF_L?i!IZ|OHI+aQE)z9pvzK<&Iz^0u#!hb8ljFG2 zenC)->D_<4^n|218pKYQEB+Y7A%UdOjhKEUDQLG=24`1=o~37HfaF?ceshvVR9o-F zgJ;U|&4i1xx7@udySDI-&#oA+@L2IPFvp^tpi>E^RA><9Bba zT&7J-BNV?6>0uk%R9pkYm2a`1URMO7Ek*5TxUaQb@3l9hrDUP40t!9^p(Y_{+F*_a zUX|zdg@xTmQ;L|>22FbQEu3gIMfy@n>&yeeFfD?n=~v zxb8|-5{8aYGQC@|j!b9TE3We{_G!BfJ&;>|6W`lvwH(h$iVqxY6Hr-?cKuoooD@Y@bYJh8fjqM_Jf1 zzcE$)1fN1RIM7~AmCZ9;YQlxdJcgBKjtuK$=hoYzV7p7I z$rQ_8?om4bc)naMJs;{S>jV(G9B8SBlGI}Miq~eX zn5{qlZ9sX-+~xA;uE!svHIEHn&5u2C?HA76I1j(%;vkAVt|*CiXK+=NxGZ|)W)?@b z58f^fmYL|zWhFy6~|OoSgb-7i6qI=cJ}0!*e=^nds!Xu376wT;eh}9LeN*F(v<^O z`Q8tzgPod+7;3A;rEZYZYEdl3_y_1mNcTN36%Ga3%w9x5PZACTUh8Ka@f~ky-)!pv z8vr}0_*tc)RY0du5k4dU){tT%V&Ni@5MvM#l9MCL#=asvP+Pc62v~UFk&fwTD3KH8 z!a%*4z<5F6de1-xwq&Bon~}O5*ULl+fhB5Ets5-U-y!nAtn z*7#q&>utof2^c=E&}Q3-?5xNxd&wO@a=dW=cTTGF$S(X07kvS>8V8CQQ*bIqNU0Qd zkU1_caK%-}LC0HZwE%YQ7!^W?Elxd_Bs>3|gPV-We>3!wtN5)uIolaPY$l%}EZs2BvY zX~EOK4Ezk6~~Pv6Rnw~qh4{*_ev_tBL3kBq^+7q4TN{;!cH zTyv9J-mUE<5pB3`Q}H2Zxs*Eh0T3w|fm1OI!!f{Uj6s=vpk-LUX-HG}hVFHlW(@V3 z_`7Lvmamrjrm;dm$zVl1>|>$)?hn8D)h)N(ao0WfrK}aMpZZND!9_=e$r3Y|v_gDI zxsWF)2-lHkki!QCCeoHuA}}&RWN(fGN9Z)u0k`EyxL*uGJdgzzY44*B;n##vS)8MR z+d<8zwihzD``cN)oE|-Pc!&!nMZFY)t?@y(s06~#k*9Ls&}TNJQdR}jzMtv+fc-K!+!iNvuEwePUt}~ZNY}~=XAkZ*fW>L{kRu*<4)X; zTk%Kv-H@AZxbB*(rkjS1g^7WVhWd^IE)5wA(6EO5j&jvaXur7!A513WPGvU~xTd{^ zfg=Vn>$4)QxPJAU2FK}dGEGh%vo>5R5;hNv;ut->%Q7=MQzGZle(6C};j6B>?uMJ) za!Of76nyS}qSSRTX*~mn%`E`(y?|wjKxTqHI6jCvLl#H{>iL=lxOIAo$R~nc0X2GX zpbb^4f$M?gfFl6lp#49en1Mx85y*b1{I>;so&Y+3e*xHK3IrHQajSU) zNXicANuP;hqW4jWsuOjwuGaMh1~DNnB!DDP0c3}E9;bjPpuB$%Mnn_H`nmm9KA z?k_V^yAF*yb!pOS1fW5JBZBp=0c2``S{3j;FzGk|W?Tlq(4Ihh9zX%0dRJglA;4TJ z`~e{ls2a@RH#Zo_mggE%C)WQ0(xei zO-4N3 zZk8O}XK&0+tN!oB>gVmAM6RmPLyVxQTg#$r!m)#a#@2QCcZBSjSx|7vK#Y2A1^XIu zbrPzV$dDw!t7&MaF|ttQGc>qAtdUbD?gEpNHkJ1^3fH|ZNe+&(4T zXc;jA0t94eN`W%1)8`mDCzlbRqpZOy{zRGM&Gi$&^5Hz^$Y^0d84Lgpnmk=ur zoGO<#5~3ASpc?wqKbPL}dMDW)KGS{(k?Yuk973q8jc2 zE3h~wZF;)QX{#C%am$yu#(<88*?`DxV%F=Z8)RsLf~Yj~Gp|^h-(c0#))Mk^-HBNJ zFM@#Kvrp0jA#r9>TGW+lb;+5D)d=M@|xmRXM;Cdc8rDk9) zc7>e8XM{nD_qOv_AhG#TW^W3GuX_#eT`N#$u5DDiqOo$Dhh}T?q$P<4ID!<5YT!5m>n& z#9kZkA0sT!jV-M32AwEde~z-PrPV21`fC|!49FPS7?+}KMLgOUjdFN^5s+K`AC0J2&T{Euf7HaZ3LmTO8YXZ^nDa1Kvx!wQW^j5-vQRO zDG2tz0SL7bEd7Z9*I3~n=?7HEXl48*JU_iLb`3q0Jfs|9B0Z17q#(Bedb4-{2_f~); zh96t2eI<1kUiIijPiw^1Vf!IgTHW&DRGgZe1)iR0z>Ee=j5f!RNmvrtF2pxox&lf? zmh+`H!r;xqEy93$hdcI^*`mdPbi9fo6ciRryD$jwf) zMk$vz7}=m72#`{v!xj}U{KEjKWGwL%0fG(Kg5(h&uh5WkizBmNb29J*hc~8P9(oRS z2m&;#uLf`V(lpAlEod@lk}sT1TRrVMzMpi=$mLda8W|5d_Igm0G9+~se=FA;&lBtM z6nq>S1MH*9xpXSlVTF~}$U@oi2`QNenZQpV3(JlmWTynsQ_6=J$2I;$Yp|B+ASDG4 zFtRX5av@%Ml+dCXX6Y&mSdT4Az8aDAx=WbB9~b`mHM3>=Rr-{mfe;RoA8SU*T$IsWWS$@1Pg=Qx zxbQDg?xAwV_5{)BoN^*R&ui6j01meQiYseYN<^TS{^)8o9Y<|3>6|{JP4yMXc@ej6 z59GL3w1mW&_g|$PI|Es^89_~o&qFXnglyw1syG(ZOk*ROM`y6E_3%_PfW8?%oCJOHUh4|EJ5>-wXXCnE{Ha7eTI4fX_JMY=E#2k4zRY@>p*xBY5D6 zKXNh{Hi58# z@jXfIF(-eVf>@)Vrv-WiRb{xzMWCO6q~Si0a~R^dR#$YS&qGQ{GW4ONI{2f>Moa>W z$@ewgx|KQ71S*Tj>Q=7PV4{f4rG-7#wg9Q82q~4!lShoiqKInoY54^BmVV^C3;O`Y z`g^3^poKio7nbiF8MQrk;NqbZdYwV=zdk&a0%`%G=X%#*Rv;e{penkAJcyF z;vAuG&x+9i04BLNGixwfNBn=)9tH4rpJqP{ zn$Z|I44xrNI5{Vuo{jF+s$G_vS9N*6^771lG>9CH%H)IVS^x+t&h*xgu{y#LHYZp~ z(6UWT(1?Rzu1Y;4h$beE!>W^wbO5}49ek)>Y2?@xX698@S$mr!WE4qT z_J^HLnLVfF9;5dvvs)?^K7mQ!4L7YYx1i8g2;e{{#l@ zJo!^YO2G3FvUH@i96x@r%_3)+xzNvYT_hMJAjsZ2aj|57i(Grz@r{e#}@Px=JvL9ge}bzu0l|@ekG1ksrx3N?LV5r@f?Q zH@*3?x4B$xD$a_o-i4jV%x&s9f=Hd7gFdXRow!`<%dQ>f;}G56 zC>w8^=1SRtRa7HLy-SorP-`)VOhux`5%t-)19c)XM`DK z=GDm6v}ti5p$>9^FS=snDE1M#w*0huoOX=p2Q<}Okrgme->!_@=tx7NxmDH8?bNk% zLN@dA!bav)l+t|>Yc(n%FaP%YHf((j)|7$}v%NhLp~?4mRG1@o|NrOzTz}vNo5m2z5cKH_SRgXepzaK#f9 zs1KtP9k4QH6}|LElm=92CC#>bxj?Xd_mZk4G5;y_YoyXO`e&a%RfJesXqk4fYWfE- zpgbb&pA`Gr;|a?4{AH#|Wp}rf@r142lv5vV;%4}K8TnA%khs7jXgH81n$f9tTH+!% z#a0awe|aQHR1x8Wv*+oqE4hzCf^VdXTT+XysIvVG5^R00R-rql7^a{*)GDxDKFRZo ztJIwGPktNv_9WOhJROHD5siQD zq7LHPax&9VfRT7!_8gqlAvu!sXe~waSrVu!caJ~b-OXb6J5%>ABc_?_HAy5Up<}wy z^wS+wi>G$l&sh&8wa27W&pE*rgotg;HJ`cxz8=1dP!imfib>7ohE%Sh8`HnZv9P&W zuXoZp?_`}K>%Uvevog!B#rX}%$H-Tn3u73PRTgPCITlKbr-l(B zb|Cl`tJZ5kF{RnCs-=CAz6zxI*Voa!8cpP@)n=TqFf1&f`s5NuP-_Wi<&PM%dgxo> ziR-8|v;yt_Q!V3PyLOgtH@m>b)SzOoWpiL&bvXQqKQ%bTxk-I2wHmxRAZI(L#M$|l zgvda#ayg=ZrgYaEx+NTYqo13XALNUMaXIiWEvC_i=843M4_Jvn`0hCc8aQ`v>}PqN zm$|rerw&5Uxl`wf)Yp{m@)as8c=Eq*zPj=`fcs0DXkM*4dSbN&D=Z8P@+pfS1Y<&4 z;N^4;%@AJy3PnL@qXT)(xthrdR?8a}GJJauhbB6Q0`1)|f?tSiDbSv9p*P=qz^*aY z-xw}JjFM+`b5EmKf+fjcs+aInF)0BJ6>2n%F?2HJF_c-k{wA|e4lTS{b%~c3;H=`s zjd`5d?wC-oiQ!t+Pz90MI1}xblw|jWqoZ;?o^g3)m3fn6<(etg_3{Pr# zrlw-8dUTt8LvNSuqY)j;bg{@nZFf`2yh=N_RpWFC{|iyJA{7kGUtto(oGr8zNI9+x+z&0IgTDNP=qD{oF1ri^W#HGR&&!1Ot@vd&B*PC5e; zhqgqW-w48j2enasUnCXp!jtn{24K@CMC;i%g;2Rw_$J{lj7^yf&%PYkp+yd0-Ok&FHT0}+0Va#6a7G!CBM^RtVpmA{YX!90HRNsy`KY(wp?b8U2 zl`g==U1uKNIOJIlRI&UjOM|o7QDcdo=F}eY|JE_3ZBF&{C$KsqFjiJ*r&m^vRoZQ1 zlogeOR^jPFsnljaYL%S1xx9qeQs9b7bh@a7Df5yUisIP&E0B47(VCtflROsEj-+|~ zu=*V{o6=OVVmRKT4q|$KkjwrEIM7tyX$7R?oaHt8inN9!9-NKp)z?sc;)2wLgT6@FUyB)R_HtZLxt*-BgvK+jo+B?R5t7{w~4kx#&{CWQ5 zR!O)^*EsGQTRROGNaIeenSp;I(dOeeT|&x`0!PZ_+&c9KkDbiCh)(NnJ*=>Icl6kR#nm9 zl}NtZQjL50qLE-R)rxHnQB#SD+gRvINb{9;8Vac44rzbt;F z%@I32CpW^mDNI_7{2H@8DJitn5z{Y?ozOW^mB78ihmM8tim2P-2Ft&@BlCvp!^&ap zLYDY@4hX%d#-Ubj=M;wpSW=lzl*H#@Y`+l2xX0(4EqlSPBr=(kWI${-#DMJuQjTHb z=h5Cpi_v+rjPH~0nkwty=lH%aylBpkR+~9s45_=VdUv7qr~R*zFRF1@`&Le%lDpfg z*6aj-WyE1FM`>?0ZaD1&!p{*9eDS=E;85F`?M;{WR`X0=!5(axR+Pl&AT6^u335N% zHtqv~5oK(^$38qeGVe$-hSZ)o-?U?GGzXZ7V&S^dRUy&oj4rgExVT8zWR7a8GnEHg z>~VG>KJU>lvp# z8fXp-yTT33EuP?L_qDmJ_rL;uKpLRqJ2nsxX7P@>srnR;5m~UxDsVl1EO1#@6(EhC z6up~roR@WwxS<122Wbdg*=J=ylosRaIqs&|sK>T*dXonC3M1r7yZ3O}_?l;{Ob^xg zKP}JSV8&0>QPW3tC{}$&3XYw{l}w$!qTzm~>o$go&7`9)XS$B#%gY@$`M2Xq0c4b< z89gwO*jwq2py-)cCMMzryN9DG85(i(4j0u+u8^YAPyl@nHho@aU0Kf12{^3P*Uf^S zPX<~tg7uYVaii1TEGnVZ=)E5jBKxQ%%_1I2+(=j-o|)ZLmOrJU!Z>-M^bQ@SjU1dF zHe4nJNsmL}^@Ec^VLJFGzTN+<C=$HKQpt7>Om~=b`1}a9v(MlSVD+nuC|Y47PN9fe+3>T%cmc^K35V zXA7dU7iq{oH{I_wSt%o0=f8ZuO2c@6vSC%TqYGZR6uDM<96TL$(t8rAPVXMh56wb3 z@pD|bq&FEY<<`vzQt}k1k5Pke6YKPCTplQ|EIQ%#tn`%rU08U%v~&&j{BVm0{qe)n zW@V}8wULwTE}ypBeL`QvRz6ryr>}pYWOEdmC0* zWxGPFMgHdO$gxJ3n7NZup$<@cp=_Y+vnM~Wf$J_pe*PqpF!8j|%t}8ZV`8Sfx;Ocp zz)=?}*OZZ#lYL|DB&~E3pLlf1@T2V~Ipg)nzwezlh`sH;23vx>X7}&)=Nrzdxy*)H zqfnMX%UKnV*)UE6s#|FglL^t9saxh5!QYi}!?a}eggm>Q=|ZWu_;Mz9%qs12`Wvze z3z;(I-mLl0mIiaO9V{Wsr&e*jS_ezY-14ea-Fet5)YzQ%;-Y{_3jLUE?VxW4=cZBj zS(-i~;0eOxWIXpCzaVR*@OS9fQDI~jziUD##9MuAHH0Nt5x6Av9h26qUnlw;yqg&h zrHv0h<@0RYl&<`c%eKrl<>Nz)L`8{3+)})H@$u7Jo_s4&9+isc$k4s(f$}2T%?7G| zs4*&Fvjx#cXc}YX3Ab{&TZO}m1lkQJKM#F=GN`8-!PgLJoFaX;Y*Z#t94kRVzkeRF zu-DU>=^Vn({R&$i%q~zmEJiTDSJ%rV(Ec4mAsHFiTeF|w7{66Lh&C~3e+_J+%%)hDBznR02Mgjez zCgtUfV8&x-c=LY3gq-r~w*1bnQ?1*YR;Zk(=6JQP;fGA^w}1!rQ={HlDd`V7&-?mn z2R958my*BSNejOPt3B!MWDI#jY+st~he7Y)qe*ALj=F`NUit@28Gl zyEIFn4iNf8{=$~LxkRvej!=#fQ<1IWgh7@Dw1;2)g+pvw<|1xIV-~-7)I1hN=&gUm zThQf+5}!ijU{vbOz{ly4Td7Szv!FxtG<$xHl{(%~QVZ*#WsQRe+(;)(5A5xL~}k zB8GK_PVDDa8dRIV2Vj;mAwx70m}$sCQ=HAlnUV&AoZ^>2JiOo!o?Mp((9s3()$1COExYKuF(+ z32rQB9W9-NbYq=7@aWhlHloEE2`qyG3`5xm)Ds*gI0!&q8;%4wIvO1YHYUu#a)kQA z^M>;PUM_5x+j$4%4%|x1UM~SPExbNGt<{fs#LN*c<VWwFwHcg{r^(Y( z#>Yu;F7o!US!Rv%4D*~@%y;g!K=jWGSpqy;mf?xAEVUb=pRmH&)~`6v=og$@`j>LQ zQ1QU~ggP_6B&+c`S)Ka1aqjhn=${w)3GhZaA1{;hQ`bQBlQ2c$4rFf`VmpCvCo>MV zwH)cB_o&qVIsOx{+h)7m$&9n+wp;-2$KTJjy#Z`9UU1U#BJRiG{N77(#AdtN$&8C_ zEmu0}y^8k(mmkb_DQq*&aME%f?#I=(83$5Ghtp#537y_A!2ho#+Fa92chxo5-EcEH z53N^0nx0-|+SWON=N0iCIRYotUbaI_*=NK@z1g7Imb`m})W5h&@?~CP2EZmNBi|oRr5)LT zeuz{9?F4m_&~6U-_t^zVpzdg!Ag`4tkntq6X}tuH*HSoefHx((B`p2DCF9HbzYq(S z7QV4fprAjjO9>#a^$;0PSgK6~c`bz_4XESpvh4me|8Gd-!gi`5aTCF4n*Jm&BZ*8d z%!O6yfEW2fCcvM2>VE(k-b?=$$P6t+(ZALZO@CZ0BtkCCgFyw!olnB zV~bVySIsylafx5>k%vQ|kYz#)2nkCpvIxjvx?+$|mjUQJKsy8ga0Am_5jU_n{WK*v z3Op$+y<#ufK8J*ACN6Tyul*YOvl{THw^ytKsVmj}VZqN>;n3oh3?OfUa(;iv;P|Hw zVQS}rH2$Y`G#0(4sYrVcM{a+YhtGHzz}vr?Z>2l6IMYWm_8!3N&*2KdJBydSujsb1 zE81DWNN@lF+JoJOJ@!aPN*)!AjmT6_sHy0?muGmUC>|>Y7+s2FpD!Az6ad4#%=Dhpd|< zs*8g9e9;OCF|_qwI#ZTRXixg+y0+;Otrc7SylAMNr~}MDG05>t7ryoi*8-?^A9nRY zs|0DaH-0?S@pX}g#M#Tu-~CO-QdB6}@UEg?5ja6%sQZ`slQg)Jvb zrjq0_uIx}hcDm28RS~G(@QSqc`IEq zS^Ez?CAF4`3`hbK)H99%PNsmHkUXyo^+@z;0gLwQIlVCmsghBKVakqEJf)z4=_uR zCO^ULhFsO33#KY}864IoD81o>{^<>?ga8{D3Ao!V+6@L{A}kcW*bO0%dAAg7qTNy< zv9&aL?*vZPV6+~ijHVRPtkq~;>Wz>mM?OvX5`m%%*;)8m(D`)nUVPNE(q@=m2D^OjQ@b^2v_O+DHT%pLzV(%_edE3s-?`+nJz71m(2u_NgEoKp z$4njCb?Vlo$9lsI*K354MjK^}K7Sf(ym2P@K)<6tGGL-fKJ>SL9rIJE8FIskq-R7% zX4HU0FWe}oXy_Q2SlBqYcp!WNLLy=kQZiZOvMDI#$nC%KsQQ2E&ZMEGqi2xMs6Zi; zBE?G1gmT+iqr38!XUTil+0FJ#*Gz_6KZy@|t8OS#e}W^P>8I%a*g-lN=9F8y+#6XQ zga;EQQP*q=Bx;1YJ!QwzrJiT7!W-75q-xae#)A=xz*?madk?@|1x8e0a~0sIf@fE1 z536jfBlXUxz2B70&PNv5D154JV$2>LH*h~`_a36twl~{g#&$HuA}K_7bIW!tRKZ*x zn)@~I1uw8eN_2z*yMIM9cuWk`9e@V_7*Udj@+!GtA0=bu$ml7+z0hvLN}vEpK%0kJ zr9-rQWrj*iW4JbUG#Xn}F_-$T#TwS7N8hO3%pFGIsN0#b4;(05{h1@ngJsCR>;RzK GVt^gpg(SuR literal 0 HcmV?d00001 diff --git a/client/packages/lowcoder/src/assets/fonts/ubuntu-500.woff2 b/client/packages/lowcoder/src/assets/fonts/ubuntu-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0c8b97fbf56ba30cb7b09fd589da7a1ed41b5d8c GIT binary patch literal 14264 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 a>uPRnH41d|Nw1!fBRfXIy4nvK0001c%e`Cx literal 0 HcmV?d00001 diff --git a/client/packages/lowcoder/src/assets/fonts/ubuntu-700.woff2 b/client/packages/lowcoder/src/assets/fonts/ubuntu-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..598bf6a6ed00f737186a3e122524e1d0b96e91f1 GIT binary patch literal 14920 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 void }) { + return {trans("admin.retry")}} />; +} diff --git a/client/packages/lowcoder/src/components/layout/AdminPagination.tsx b/client/packages/lowcoder/src/components/layout/AdminPagination.tsx new file mode 100644 index 0000000000..004fb655e9 --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/AdminPagination.tsx @@ -0,0 +1,26 @@ +import { Pagination } from "antd"; +import { trans } from "i18n"; + +interface Props { + currentPage: number; + pageSize: number; + total: number; + setCurrentPage: (page: number) => void; + setPageSize: (size: number) => void; + simple?: boolean; +} + +export function AdminPagination({ currentPage, pageSize, total, setCurrentPage, setPageSize, simple }: Props) { + if (total === 0) return null; + return trans("admin.resultCount", { start: range[0], end: range[1], total: count })} + onChange={(page, size) => size !== pageSize ? setPageSize(size) : setCurrentPage(page)} + />; +} diff --git a/client/packages/lowcoder/src/components/layout/Layout.tsx b/client/packages/lowcoder/src/components/layout/Layout.tsx index 04acd92071..cfd72de1d9 100644 --- a/client/packages/lowcoder/src/components/layout/Layout.tsx +++ b/client/packages/lowcoder/src/components/layout/Layout.tsx @@ -12,6 +12,7 @@ import styled from "styled-components"; import { useSelector } from "react-redux"; import { MenuOutlined } from "@ant-design/icons"; import { Drawer, Button } from "antd"; +import { trans } from "i18n"; import { getBrandingSetting } from "@lowcoder-ee/redux/selectors/enterpriseSelectors"; type LayoutProps = { @@ -24,26 +25,30 @@ const SideBarV2 = styled(SideBar)<{ $activeBgColor?: string, $activeFontColor?: string, }>` - background: ${props => props.$bgColor ? props.$bgColor : '#f7f9fc'} !important; + background: ${props => props.$bgColor ? props.$bgColor : '#f8f3fb'} !important; ${props => props.$fontColor && `color: ${props.$fontColor}`}; - padding: 28px 10px; - border-right: 1px solid #ebebeb; + padding: 20px 14px; + overflow-y: auto; + flex-shrink: 0; + border-right: 1px solid #e9e0ee; .sidebar-section:not(:last-child)::after { content: ""; display: block; - width: 204px; + width: calc(100% - 24px); height: 1px; - background: #ebebeb !important; - margin: 0 auto 4px; + background: #e9e0ee; + margin: 12px auto; } `; const MobileMenuButton = styled(Button)` display: none; position: fixed; - top: 75px; - right: 22px; + top: 12px; + left: 12px; + width: 36px; + height: 36px; z-index: 1000; @media screen and (max-width: 720px) { @@ -59,7 +64,7 @@ const DrawerContentWrapper = styled.div` export function Layout(props: LayoutProps) { const [drawerVisible, setDrawerVisible] = useState(false); - const [isMobile, setIsMobile] = useState(false); + const [isMobile, setIsMobile] = useState(() => window.innerWidth <= 720); const brandingSettings = useSelector(getBrandingSetting); const toggleDrawer = () => { @@ -100,12 +105,14 @@ export function Layout(props: LayoutProps) { return ( - + {/* Mobile Hamburger Button */} {isMobile && ( } @@ -115,10 +122,11 @@ export function Layout(props: LayoutProps) { {/* Drawer for Mobile Sidebar */} setDrawerVisible(false)} open={drawerVisible} styles={{ body: { padding: "0px" } @@ -127,6 +135,8 @@ export function Layout(props: LayoutProps) { > )} - + {routes} ); -} \ No newline at end of file +} diff --git a/client/packages/lowcoder/src/components/layout/SideBarItem.tsx b/client/packages/lowcoder/src/components/layout/SideBarItem.tsx index 695eb4e196..1e052307e0 100644 --- a/client/packages/lowcoder/src/components/layout/SideBarItem.tsx +++ b/client/packages/lowcoder/src/components/layout/SideBarItem.tsx @@ -6,27 +6,35 @@ import { useLocation } from "react-router-dom"; type SideBarSize = "medium" | "small"; -const Wrapper = styled.div<{ +const Wrapper = styled.button<{ $size?: SideBarSize; $selected?: boolean; $selectedBgColor?: string; $selectedFontColor?: string; }>` + appearance: none; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + transition: background 150ms, color 150ms; width: 100%; height: ${(props) => (props.$size === "small" ? "36px" : "44px")}; - border-radius: 4px; + border-radius: 10px; display: flex; align-items: center; - padding: 0 8px 0 26px; + padding: 0 12px; + gap: 4px; cursor: pointer; &:hover { background: ${(props) => (props.$selected ? ( - `${props.$selectedBgColor ? props.$selectedBgColor : '#ebf0f7'}` + `${props.$selectedBgColor ? props.$selectedBgColor : '#eadcf3'}` ) : ( - `${props.$selectedBgColor ? props.$selectedBgColor : '#efeff1'}` + `${props.$selectedBgColor ? props.$selectedBgColor : '#f0e6f6'}` ))}; - color: ${(props) => props.$selectedFontColor ? props.$selectedFontColor : '#4965f2'} + color: ${(props) => props.$selectedFontColor ? props.$selectedFontColor : '#683e86'} } svg { @@ -36,8 +44,8 @@ const Wrapper = styled.div<{ ${(props) => props.$selected && css` - color: ${props.$selectedFontColor ? props.$selectedFontColor : '#4965f2'}; - background: ${props.$selectedBgColor ? props.$selectedBgColor : '#ebf0f7'}; + color: ${props.$selectedFontColor ? props.$selectedFontColor : '#683e86'}; + background: ${props.$selectedBgColor ? props.$selectedBgColor : '#eadcf3'}; `} `; @@ -47,6 +55,8 @@ export const SideBarItem = (props: SideBarItemProps) => { const currentPath = useLocation().pathname; return ( :not(.text) { margin-left: 0; } + } + details > .ant-card { + margin-top: 24px; + color: var(--admin-muted, #786a80); + .ant-card-body { padding: 20px; } + .ant-btn { + width: 100%; + max-width: 100%; + height: auto; + min-height: 36px; + white-space: normal; + padding: 7px 12px; + > span { min-width: 0; overflow-wrap: anywhere; } + } + } + + @media (max-width: 1100px) { + width: 100%; min-width: 0; height: auto; overflow: visible; padding: 20px 16px; + border-right: 0; border-bottom: 1px solid var(--admin-line, #e9e0ee); + .ant-menu { display: flex; flex-wrap: wrap; gap: 4px; } + && .ant-menu-inline .ant-menu-item { width: auto; max-width: 100%; margin: 0; } + details > .ant-card { margin-top: 16px; } + summary { pointer-events: auto; cursor: pointer; display: flex; align-items: center; justify-content: space-between; } + summary::after { content: '+'; color: var(--admin-muted); font-size: 24px; } + details[open] summary::after { content: '−'; } + summary h2 { margin: 0; } + details[open] summary { margin-bottom: 16px; } } .ant-menu-item:hover, .ant-menu-item-selected { - border-radius: 4px; + border-radius: 10px; } `; @@ -33,10 +87,22 @@ interface IProps { export default function SubSideBar(props: PropsWithChildren) { const { title, children } = props; + const [compact, setCompact] = useState(() => window.innerWidth <= 1100); + const [expanded, setExpanded] = useState(false); + useEffect(() => { + const media = window.matchMedia("(max-width: 1100px)"); + const update = () => setCompact(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); return ( - {title &&

{title}

} - {children} +
{ + if (compact) setExpanded(event.currentTarget.open); + }}> + { if (!compact) event.preventDefault(); }}>{title &&

{title}

}
+ {children} +
); } diff --git a/client/packages/lowcoder/src/components/layout/adminTheme.tsx b/client/packages/lowcoder/src/components/layout/adminTheme.tsx new file mode 100644 index 0000000000..ce3efe963b --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/adminTheme.tsx @@ -0,0 +1,142 @@ +import { ConfigProvider, ThemeConfig } from "antd"; +import { CSSProperties, PropsWithChildren, useMemo } from "react"; +import { useSelector } from "react-redux"; +import { getBrandingSetting } from "redux/selectors/enterpriseSelectors"; +import styled from "styled-components"; +import ubuntuRegular from "assets/fonts/ubuntu-400.woff2"; +import ubuntuMedium from "assets/fonts/ubuntu-500.woff2"; +import ubuntuBold from "assets/fonts/ubuntu-700.woff2"; + +// Website brand tokens, scoped to the management area. The app canvas and editor +// keep their own theme; enterprise branding continues to override the shell. +export const adminTheme: ThemeConfig = { + token: { + colorPrimary: "#8050a0", + colorLink: "#754394", + colorText: "#281a30", + colorTextSecondary: "#786a80", + colorBorder: "#e5dce9", + colorBgLayout: "#faf7fc", + borderRadius: 10, + fontFamily: "'Lowcoder Ubuntu', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + }, + components: { + Button: { primaryShadow: "0 3px 8px #683e8618" }, + Table: { headerBg: "#faf7fc", headerColor: "#786a80", rowHoverBg: "#faf6fd", cellPaddingBlock: 16 }, + Menu: { itemSelectedBg: "#efe3f7", itemSelectedColor: "#683e86", itemBorderRadius: 10 }, + Card: { borderRadiusLG: 16 }, + }, +}; + +const ThemeScope = styled.div` + @font-face { font-family: 'Lowcoder Ubuntu'; src: url('${ubuntuRegular}') format('woff2'); font-weight: 400; font-display: swap; } + @font-face { font-family: 'Lowcoder Ubuntu'; src: url('${ubuntuMedium}') format('woff2'); font-weight: 500; font-display: swap; } + @font-face { font-family: 'Lowcoder Ubuntu'; src: url('${ubuntuBold}') format('woff2'); font-weight: 700; font-display: swap; } + --admin-ink: #281a30; + --admin-muted: #786a80; + --admin-accent: #8050a0; + --admin-line: #e9e0ee; + --admin-paper: #faf7fc; + --admin-tint: #f3eaf9; + --admin-radius: 16px; + font-family: 'Lowcoder Ubuntu', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--admin-ink); + min-width: 0; + height: 100%; + + .main-content { background: var(--admin-paper); min-width: 0; scrollbar-gutter: stable; } + .top-header { background-color: #201329; border-bottom: 1px solid #ffffff14; } + @media (max-width: 720px) { + .top-header { padding-left: 60px; padding-right: 16px; } + .top-header .ant-row { gap: 12px; margin: 0 !important; flex-wrap: nowrap; } + .top-header .ant-col { padding: 0 !important; } + } + .admin-brand-mark { display: flex; } + .admin-brand-mark svg { width: 24px; height: 26px; } + @media (max-width: 360px) { + .top-header { padding-left: 54px; padding-right: 12px; } + .top-header .ant-row { gap: 8px; } + .admin-brand-name { font-size: 16px !important; gap: 6px !important; } + .admin-brand-mark svg { width: 20px; height: 24px; } + } + .ant-card { box-shadow: 0 4px 20px #39204e04; } + .ant-card-head { border-color: var(--admin-line); } + .ant-table { color: var(--admin-ink); } + && .ant-table-thead > tr > th { background: #faf7fc; color: var(--admin-muted); font-size: 12px; font-weight: 500; height: 44px; } + && .ant-table-column-title { font-size: 12px; font-weight: 500; } + && .ant-table-tbody > tr:hover > td { background: #faf6fd; } + .ant-table-tbody > tr > td { border-color: #f0eaf3; } + && .ant-table-tbody button:not(:disabled) { opacity: 1; } + .ant-table-row:focus-within .home-datasource-edit-button { opacity: 1; } + &&& [data-button-type='primary']:not(:disabled) { + background: var(--admin-accent); + border-color: var(--admin-accent); + color: white; + } + &&& [data-button-type='primary']:not(:disabled):hover { filter: brightness(0.92); } + .ant-pagination { flex-wrap: wrap; row-gap: 8px; } + :where(button, a, [role='button'], [tabindex='0']):focus-visible { + outline: 3px solid #b480de99; + outline-offset: 3px; + } + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; } + } +`; + +export function AdminTheme({ children }: PropsWithChildren) { + const branding = useSelector(getBrandingSetting)?.config_set; + const theme = useMemo(() => ({ + ...adminTheme, + token: { ...adminTheme.token, + ...(branding?.font ? { fontFamily: branding.font } : {}), + ...(branding?.mainBrandingColor ? { colorPrimary: branding.mainBrandingColor } : {}), + }, + }), [branding?.font, branding?.mainBrandingColor]); + return {children}; +} + +export const AdminPage = styled.div` + width: 100%; + max-width: 1800px; + margin: 0 auto; + padding: 32px 36px 40px; + min-width: 0; + @media (max-width: 1100px) { padding: 24px; } + @media (max-width: 720px) { padding: 20px 16px 32px; } +`; + +export const AdminPageHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 24px; + h1 { color: var(--admin-ink); font-size: clamp(25px, 2.4vw, 34px); line-height: 1.2; font-weight: 500; letter-spacing: -0.035em; margin: 0; } + p { color: var(--admin-muted); font-size: 14px; line-height: 1.6; margin: 10px 0 0; max-width: 650px; } +`; + +export const AdminSurface = styled.div` + border: 1px solid var(--admin-line); + border-radius: var(--admin-radius); + background: white; + min-width: 0; + box-shadow: 0 6px 30px #39204e04; +`; + +export const AdminCover = styled.div` + position: relative; + overflow: hidden; + padding: 28px 32px; + margin-bottom: 24px; + border-radius: 18px; + color: white; + background: radial-gradient(ellipse at 95% 10%, #754394 0%, transparent 60%), #231629; + h1 { position: relative; color: white; font-weight: 500; font-size: clamp(26px, 2.5vw, 36px); line-height: 1.2; letter-spacing: -0.035em; margin: 0; } + p { color: #e0cde9; margin: 10px 0 0; max-width: 670px; line-height: 1.6; } + @media (max-width: 720px) { padding: 24px; } +`; diff --git a/client/packages/lowcoder/src/components/layout/useAdminPagination.test.ts b/client/packages/lowcoder/src/components/layout/useAdminPagination.test.ts new file mode 100644 index 0000000000..6566463ff9 --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/useAdminPagination.test.ts @@ -0,0 +1,81 @@ +import { expect, it, jest } from "@jest/globals"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useAdminPagination } from "./useAdminPagination"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +it("resets paging for filters, clearing filters, workspaces and page sizes", async () => { + const loadPage = jest.fn<() => Promise<{ success: boolean; data: string[]; total: number }>>().mockResolvedValue({ success: true, data: ["app"], total: 80 }); + const { result, rerender } = renderHook(({ scope, filterKey }) => useAdminPagination({ scope, filterKey, loadPage }), { + initialProps: { scope: "workspace-a", filterKey: "" }, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setCurrentPage(4)); + await waitFor(() => expect(loadPage).toHaveBeenLastCalledWith(4, 10)); + rerender({ scope: "workspace-a", filterKey: "invoices" }); + await waitFor(() => expect(loadPage).toHaveBeenLastCalledWith(1, 10)); + rerender({ scope: "workspace-a", filterKey: "" }); + expect(result.current.currentPage).toBe(1); + act(() => result.current.setCurrentPage(3)); + rerender({ scope: "workspace-b", filterKey: "" }); + expect(result.current.currentPage).toBe(1); + expect(result.current.elements).toEqual([]); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setCurrentPage(3)); + act(() => result.current.setPageSize(20)); + await waitFor(() => expect(loadPage).toHaveBeenLastCalledWith(1, 20)); +}); + +it("ignores a slow response from a previous search or workspace", async () => { + const oldRequest = deferred<{ success: boolean; data: string[]; total: number }>(); + const latestRequest = deferred<{ success: boolean; data: string[]; total: number }>(); + const loadOld = jest.fn(() => oldRequest.promise); + const loadLatest = jest.fn(() => latestRequest.promise); + const { result, rerender } = renderHook(({ scope, filterKey, loadPage }) => useAdminPagination({ scope, filterKey, loadPage }), { + initialProps: { scope: "old", filterKey: "", loadPage: loadOld }, + }); + await waitFor(() => expect(loadOld).toHaveBeenCalled()); + rerender({ scope: "new", filterKey: "new", loadPage: loadLatest }); + await act(async () => latestRequest.resolve({ success: true, data: ["latest"], total: 1 })); + await act(async () => oldRequest.resolve({ success: true, data: ["stale"], total: 50 })); + expect(result.current.elements).toEqual(["latest"]); + expect(result.current.total).toBe(1); +}); + +it("returns to the last valid page after deletion, preserving a real zero total", async () => { + let total = 21; + const loadPage = jest.fn(async () => ({ success: true, data: ["app"], total })); + const { result } = renderHook(() => useAdminPagination({ scope: "workspace", filterKey: "", loadPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setCurrentPage(3)); + await waitFor(() => expect(loadPage).toHaveBeenLastCalledWith(3, 10)); + total = 20; + act(() => result.current.retry()); + await waitFor(() => expect(loadPage).toHaveBeenLastCalledWith(2, 10)); + total = 0; + act(() => result.current.retry()); + await waitFor(() => expect(result.current.currentPage).toBe(1)); + await waitFor(() => expect(result.current.total).toBe(0)); +}); + +it("shows request failures and supports retry without treating them as empty lists", async () => { + const loadPage = jest.fn<() => Promise<{ success: boolean; data: string[]; total: number }>>().mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue({ success: true, data: [], total: 0 }); + const { result } = renderHook(() => useAdminPagination({ scope: "workspace", filterKey: "", loadPage })); + await waitFor(() => expect(result.current.error).toBe(true)); + expect(result.current.loading).toBe(false); + act(() => result.current.retry()); + await waitFor(() => expect(result.current.error).toBe(false)); + expect(result.current.total).toBe(0); +}); + +it("does not issue requests without a workspace", () => { + const loadPage = jest.fn(async () => ({ success: true, data: [], total: 0 })); + const { result } = renderHook(() => useAdminPagination({ scope: "", filterKey: "", loadPage })); + expect(loadPage).not.toHaveBeenCalled(); + expect(result.current.loading).toBe(false); +}); diff --git a/client/packages/lowcoder/src/components/layout/useAdminPagination.ts b/client/packages/lowcoder/src/components/layout/useAdminPagination.ts new file mode 100644 index 0000000000..a17a9324ab --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/useAdminPagination.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface PageResult { + success: boolean; + data?: T[]; + total?: number; +} + +interface Options { + scope: string; + filterKey: string; + loadPage: (page: number, pageSize: number) => Promise>; +} + +// Keep API paging authoritative. A filter or workspace change starts at page 1, +// and late responses can never replace a newer request's rows. +export function useAdminPagination({ scope, filterKey, loadPage }: Options) { + const key = JSON.stringify([scope, filterKey]); + const [position, setPosition] = useState({ key, page: 1, size: 10 }); + const currentPage = position.key === key ? position.page : 1; + const pageSize = position.size; + const requestKey = JSON.stringify([key, currentPage, pageSize]); + const [result, setResult] = useState({ key: "", elements: [] as T[], total: 0, error: false }); + const [loading, setLoading] = useState(true); + const [revision, setRevision] = useState(0); + + const setCurrentPage = useCallback((page: number) => { + setPosition((previous) => ({ ...previous, key, page: Math.max(1, page) })); + }, [key]); + const setPageSize = useCallback((size: number) => { + setPosition({ key, page: 1, size }); + }, [key]); + const retry = useCallback(() => setRevision((value) => value + 1), []); + const emptyElements = useMemo(() => [], []); + + useEffect(() => { + setPosition((previous) => previous.key === key ? previous : { ...previous, key, page: 1 }); + }, [key]); + + useEffect(() => { + if (!scope) return; + let active = true; + setLoading(true); + Promise.resolve().then(() => loadPage(currentPage, pageSize)).then((response) => { + if (!active) return; + if (!response.success) throw new Error("Page request failed"); + const total = response.total ?? 0; + const lastPage = Math.max(1, Math.ceil(total / pageSize)); + if (currentPage > lastPage) { + setCurrentPage(lastPage); + return; + } + setResult({ key: requestKey, elements: response.data ?? [], total, error: false }); + }).catch(() => { + if (active) setResult({ key: requestKey, elements: [], total: 0, error: true }); + }).finally(() => { + if (active) setLoading(false); + }); + return () => { active = false; }; + }, [scope, requestKey, currentPage, pageSize, loadPage, revision, setCurrentPage]); + + const isCurrent = Boolean(scope) && result.key === requestKey; + return { + elements: isCurrent ? result.elements : emptyElements, + total: isCurrent ? result.total : 0, + loading: Boolean(scope) && (loading || !isCurrent), + error: isCurrent && result.error, + currentPage, pageSize, setCurrentPage, setPageSize, retry, + }; +} diff --git a/client/packages/lowcoder/src/i18n/locales/de.ts b/client/packages/lowcoder/src/i18n/locales/de.ts index c7056fd9ec..81aa592e13 100644 --- a/client/packages/lowcoder/src/i18n/locales/de.ts +++ b/client/packages/lowcoder/src/i18n/locales/de.ts @@ -2,6 +2,75 @@ import { en } from "./en"; export const de = { ...en, + "marketplace": { + "eyebrow": "Mit Lowcoder erstellt", + "title": "Inspiration für deine nächste App", + "description": "Entdecke nützliche Apps, ihre Funktionsweise und die Menschen dahinter.", + "community": "Community", + "local": "Diese Installation", + "shareApp": "App teilen", + "shareHint": "Öffne eine eigene App und veröffentliche sie über Teilen im Marketplace.", + "search": "Apps, Ideen oder Ersteller suchen", + "allTypes": "Alle Typen", + "allCategories": "Alle Kategorien", + "category": "Kategorie", + "categories": "Kategorien entdecken", + "appType": "App-Typ", + "sort": "Apps sortieren", + "nameSort": "Name A–Z", + "newest": "Neueste zuerst", + "results": "{count} Apps", + "oneResult": "1 App", + "loading": "Apps werden geladen…", + "openApp": "App öffnen", + "exploreApp": "{title} entdecken", + "previewAlt": "Vorschau von {title}", + "previewUnavailable": "Vorschau nicht verfügbar", + "loadingPreview": "Vorschau wird geladen…", + "previewCaption": "Automatisch erfasstes Layout", + "openFullApp": "Vollständige App öffnen", + "backToMarketplace": "Zurück zum Marketplace", + "aboutApp": "Über diese App", + "createdBy": "Erstellt von", + "moreFromCreator": "Mehr von diesem Ersteller", + "communityCreator": "Community-Mitglied", + "uncategorized": "Ohne Kategorie", + "workspace": "Arbeitsbereich", + "source": "Marketplace", + "copyLink": "Link zum Eintrag kopieren", + "linkCopied": "Link kopiert", + "noDescription": "Der Ersteller hat noch keine Beschreibung hinzugefügt.", + "appUnavailable": "Diese App ist nicht mehr verfügbar", + "appUnavailableDescription": "Sie wurde möglicherweise entfernt oder du hast keinen Zugriff mehr.", + "noMatches": "Keine passenden Apps", + "localEmpty": "Deine lokale Sammlung beginnt hier", + "localEmptyDescription": "Hier erscheinen Apps dieser Installation. Veröffentliche eine App über Teilen, damit dein Team sie entdecken kann.", + "communityEmpty": "Neue Inspiration folgt bald", + "communityEmptyDescription": "Zurzeit sind keine Community-Apps verfügbar.", + "goToApps": "Zu deinen Apps" +}, + "admin": { + "navigation": "Navigation", + "viewMode": "Ansicht", + "listView": "Listenansicht", + "cardView": "Kartenansicht", + "clearFilters": "Filter zurücksetzen", + "noResults": "Keine Treffer. Versuche eine andere Suche oder setze die Filter zurück.", + "loadError": "Diese Liste konnte nicht geladen werden. Bitte versuche es erneut.", + "retry": "Erneut versuchen", + "resultCount": "{start}–{end} von {total}", + "appsDescription": "Deine Apps, Module und Ordner. Entwickle etwas, das die Arbeit leichter macht.", + "folderDescription": "Organisiere zusammengehörige Apps. Öffne einen Ordner, um seinen Inhalt zu sehen.", + "trashDescription": "Gelöschte Apps prüfen, wiederherstellen oder endgültig entfernen.", + "marketplaceDescription": "Inspiration für dein nächstes Projekt. Entdecke Apps, die mit Lowcoder entwickelt wurden.", + "datasourceDescription": "Verbinde Datenbanken und Dienste und nutze sie im gesamten Arbeitsbereich.", + "queriesDescription": "Erstelle, teste und teile wiederverwendbare Abfragen für deine Apps.", + "newsDescription": "Ideen, Produktneuigkeiten und praktische Tipps für Lowcoder.", + "profileDescription": "Verwalte dein Profil und deine persönlichen Einstellungen.", + "blogPosts": "Aus dem Blog", + "videos": "Ansehen und lernen", + "releases": "Neueste Versionen" +}, "automator": { "studio": { "connection": "Verbindung", @@ -5165,4 +5234,4 @@ export const de = { "buySubscriptionButton": "Jetzt abonnieren", }, -}; \ No newline at end of file +}; diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index fce3ac8bdf..130abcb420 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1,6 +1,75 @@ // import table from "./componentDocExtra/table.md?url"; export const en = { + "marketplace": { + "eyebrow": "Made with Lowcoder", + "title": "Find your next inspiration", + "description": "Explore useful apps, discover how they work, and meet the people who built them.", + "community": "Community", + "local": "This installation", + "shareApp": "Share an app", + "shareHint": "Open one of your apps, then use Share to publish it to the marketplace.", + "search": "Search apps, ideas or creators", + "allTypes": "All types", + "allCategories": "All categories", + "category": "Category", + "categories": "Browse categories", + "appType": "App type", + "sort": "Sort apps", + "nameSort": "Name A–Z", + "newest": "Newest first", + "results": "{count} apps", + "oneResult": "1 app", + "loading": "Loading apps…", + "openApp": "Open app", + "exploreApp": "Explore {title}", + "previewAlt": "Preview of {title}", + "previewUnavailable": "Preview unavailable", + "loadingPreview": "Loading preview…", + "previewCaption": "Automatically captured layout", + "openFullApp": "Open full app", + "backToMarketplace": "Back to marketplace", + "aboutApp": "About this app", + "createdBy": "Created by", + "moreFromCreator": "More from this creator", + "communityCreator": "Community creator", + "uncategorized": "Not categorized", + "workspace": "Workspace", + "source": "Marketplace", + "copyLink": "Copy listing link", + "linkCopied": "Listing link copied", + "noDescription": "The creator has not added a description yet.", + "appUnavailable": "This app is no longer available", + "appUnavailableDescription": "It may have been removed from this marketplace, or you may no longer have access.", + "noMatches": "No apps match your search", + "localEmpty": "Your local collection starts here", + "localEmptyDescription": "Apps shared on this installation appear here. Publish an app from its Share settings to help your team discover it.", + "communityEmpty": "New inspiration is on its way", + "communityEmptyDescription": "There are no community apps to show right now.", + "goToApps": "Go to your apps" +}, + "admin": { + "navigation": "Navigation", + "viewMode": "View mode", + "listView": "List view", + "cardView": "Card view", + "clearFilters": "Clear filters", + "noResults": "No matches. Try another search or clear your filters.", + "loadError": "We could not load this list. Please try again.", + "retry": "Try again", + "resultCount": "{start}–{end} of {total}", + "appsDescription": "Your apps, modules and folders. Build something that makes work better.", + "folderDescription": "Keep related apps together. Open a folder to explore what is inside.", + "trashDescription": "Review deleted apps. Restore what you need, or remove it permanently.", + "marketplaceDescription": "A little inspiration for your next build. Explore apps made with Lowcoder.", + "datasourceDescription": "Connect your databases and services. Use them across your workspace.", + "queriesDescription": "Build, test and share reusable queries across your apps.", + "newsDescription": "Ideas, product updates and practical ways to build with Lowcoder.", + "profileDescription": "Make yourself at home. Manage your profile and personal preferences.", + "blogPosts": "From the blog", + "videos": "Watch and learn", + "releases": "Latest releases" +}, "automator": { "studio": { "connection": "Connection", diff --git a/client/packages/lowcoder/src/i18n/locales/es.ts b/client/packages/lowcoder/src/i18n/locales/es.ts index a52c7a2980..c8b853f038 100644 --- a/client/packages/lowcoder/src/i18n/locales/es.ts +++ b/client/packages/lowcoder/src/i18n/locales/es.ts @@ -2,6 +2,75 @@ import { en } from "./en"; export const es = { ...en, + "marketplace": { + "eyebrow": "Creado con Lowcoder", + "title": "Encuentra tu próxima inspiración", + "description": "Descubre apps útiles, cómo funcionan y las personas que las crearon.", + "community": "Comunidad", + "local": "Esta instalación", + "shareApp": "Compartir una app", + "shareHint": "Abre una de tus apps y usa Compartir para publicarla en el marketplace.", + "search": "Buscar apps, ideas o creadores", + "allTypes": "Todos los tipos", + "allCategories": "Todas las categorías", + "category": "Categoría", + "categories": "Explorar categorías", + "appType": "Tipo de app", + "sort": "Ordenar apps", + "nameSort": "Nombre A–Z", + "newest": "Más recientes", + "results": "{count} apps", + "oneResult": "1 app", + "loading": "Cargando apps…", + "openApp": "Abrir app", + "exploreApp": "Explorar {title}", + "previewAlt": "Vista previa de {title}", + "previewUnavailable": "Vista previa no disponible", + "loadingPreview": "Cargando vista previa…", + "previewCaption": "Diseño capturado automáticamente", + "openFullApp": "Abrir app completa", + "backToMarketplace": "Volver al marketplace", + "aboutApp": "Acerca de esta app", + "createdBy": "Creada por", + "moreFromCreator": "Más de este creador", + "communityCreator": "Creador de la comunidad", + "uncategorized": "Sin categoría", + "workspace": "Espacio de trabajo", + "source": "Marketplace", + "copyLink": "Copiar enlace", + "linkCopied": "Enlace copiado", + "noDescription": "El creador aún no ha añadido una descripción.", + "appUnavailable": "Esta app ya no está disponible", + "appUnavailableDescription": "Puede haberse retirado del marketplace o ya no tienes acceso.", + "noMatches": "No hay apps que coincidan", + "localEmpty": "Tu colección local empieza aquí", + "localEmptyDescription": "Aquí aparecen las apps compartidas en esta instalación. Publica una app desde Compartir para que tu equipo la descubra.", + "communityEmpty": "Pronto habrá más inspiración", + "communityEmptyDescription": "No hay apps de la comunidad para mostrar ahora.", + "goToApps": "Ir a tus apps" +}, + "admin": { + "navigation": "Navegación", + "viewMode": "Vista", + "listView": "Vista de lista", + "cardView": "Vista de tarjetas", + "clearFilters": "Borrar filtros", + "noResults": "Sin resultados. Prueba otra búsqueda o borra los filtros.", + "loadError": "No se pudo cargar esta lista. Inténtalo de nuevo.", + "retry": "Reintentar", + "resultCount": "{start}–{end} de {total}", + "appsDescription": "Tus apps, módulos y carpetas. Crea algo que facilite el trabajo.", + "folderDescription": "Agrupa las apps relacionadas. Abre una carpeta para explorar su contenido.", + "trashDescription": "Revisa las apps eliminadas. Restaura las que necesites o elimínalas definitivamente.", + "marketplaceDescription": "Inspiración para tu próximo proyecto. Explora apps creadas con Lowcoder.", + "datasourceDescription": "Conecta tus bases de datos y servicios para usarlos en todo tu espacio de trabajo.", + "queriesDescription": "Crea, prueba y comparte consultas reutilizables entre tus apps.", + "newsDescription": "Ideas, novedades y formas prácticas de crear con Lowcoder.", + "profileDescription": "Gestiona tu perfil y tus preferencias personales.", + "blogPosts": "Desde el blog", + "videos": "Mira y aprende", + "releases": "Últimas versiones" +}, "automator": { "studio": { "connection": "Conexión", diff --git a/client/packages/lowcoder/src/i18n/locales/it.ts b/client/packages/lowcoder/src/i18n/locales/it.ts index 565af42483..f4fbbb84c9 100644 --- a/client/packages/lowcoder/src/i18n/locales/it.ts +++ b/client/packages/lowcoder/src/i18n/locales/it.ts @@ -2,6 +2,75 @@ import { en } from "./en"; export const it = { ...en, + "marketplace": { + "eyebrow": "Creato con Lowcoder", + "title": "Trova la tua prossima ispirazione", + "description": "Scopri app utili, come funzionano e le persone che le hanno create.", + "community": "Community", + "local": "Questa installazione", + "shareApp": "Condividi un’app", + "shareHint": "Apri una tua app e usa Condividi per pubblicarla nel marketplace.", + "search": "Cerca app, idee o autori", + "allTypes": "Tutti i tipi", + "allCategories": "Tutte le categorie", + "category": "Categoria", + "categories": "Esplora le categorie", + "appType": "Tipo di app", + "sort": "Ordina app", + "nameSort": "Nome A–Z", + "newest": "Più recenti", + "results": "{count} app", + "oneResult": "1 app", + "loading": "Caricamento delle app…", + "openApp": "Apri app", + "exploreApp": "Esplora {title}", + "previewAlt": "Anteprima di {title}", + "previewUnavailable": "Anteprima non disponibile", + "loadingPreview": "Caricamento anteprima…", + "previewCaption": "Layout acquisito automaticamente", + "openFullApp": "Apri app completa", + "backToMarketplace": "Torna al marketplace", + "aboutApp": "Informazioni sull’app", + "createdBy": "Creata da", + "moreFromCreator": "Altro da questo autore", + "communityCreator": "Autore della community", + "uncategorized": "Senza categoria", + "workspace": "Spazio di lavoro", + "source": "Marketplace", + "copyLink": "Copia link", + "linkCopied": "Link copiato", + "noDescription": "L’autore non ha ancora aggiunto una descrizione.", + "appUnavailable": "Questa app non è più disponibile", + "appUnavailableDescription": "Potrebbe essere stata rimossa dal marketplace oppure non hai più accesso.", + "noMatches": "Nessuna app corrispondente", + "localEmpty": "La tua raccolta locale inizia qui", + "localEmptyDescription": "Qui trovi le app condivise in questa installazione. Pubblica un’app dalle impostazioni di condivisione per farla scoprire al team.", + "communityEmpty": "Nuove idee in arrivo", + "communityEmptyDescription": "Al momento non ci sono app della community da mostrare.", + "goToApps": "Vai alle tue app" +}, + "admin": { + "navigation": "Navigazione", + "viewMode": "Visualizzazione", + "listView": "Vista elenco", + "cardView": "Vista schede", + "clearFilters": "Cancella filtri", + "noResults": "Nessun risultato. Prova un’altra ricerca o cancella i filtri.", + "loadError": "Impossibile caricare l’elenco. Riprova.", + "retry": "Riprova", + "resultCount": "{start}–{end} di {total}", + "appsDescription": "Le tue app, i tuoi moduli e le tue cartelle. Crea qualcosa che migliori il lavoro.", + "folderDescription": "Raggruppa le app correlate. Apri una cartella per esplorarne il contenuto.", + "trashDescription": "Controlla le app eliminate. Ripristinale o rimuovile definitivamente.", + "marketplaceDescription": "Ispirazione per il tuo prossimo progetto. Esplora le app create con Lowcoder.", + "datasourceDescription": "Collega database e servizi e usali nel tuo spazio di lavoro.", + "queriesDescription": "Crea, prova e condividi query riutilizzabili tra le tue app.", + "newsDescription": "Idee, novità e consigli pratici per creare con Lowcoder.", + "profileDescription": "Gestisci il tuo profilo e le tue preferenze personali.", + "blogPosts": "Dal blog", + "videos": "Guarda e impara", + "releases": "Ultime versioni" +}, "automator": { "studio": { "connection": "Connessione", @@ -5165,4 +5234,4 @@ export const it = { "buySubscriptionButton": "Iscriviti ora", }, -}; \ No newline at end of file +}; diff --git a/client/packages/lowcoder/src/i18n/locales/pt.ts b/client/packages/lowcoder/src/i18n/locales/pt.ts index aefbf5a7b2..b5aa3d3718 100644 --- a/client/packages/lowcoder/src/i18n/locales/pt.ts +++ b/client/packages/lowcoder/src/i18n/locales/pt.ts @@ -2,6 +2,75 @@ import { en } from "./en"; export const pt = { ...en, + "marketplace": { + "eyebrow": "Criado com Lowcoder", + "title": "Encontre sua próxima inspiração", + "description": "Explore apps úteis, veja como funcionam e conheça quem as criou.", + "community": "Comunidade", + "local": "Esta instalação", + "shareApp": "Compartilhar app", + "shareHint": "Abra uma de suas apps e use Compartilhar para publicá-la no marketplace.", + "search": "Buscar apps, ideias ou criadores", + "allTypes": "Todos os tipos", + "allCategories": "Todas as categorias", + "category": "Categoria", + "categories": "Explorar categorias", + "appType": "Tipo de app", + "sort": "Ordenar apps", + "nameSort": "Nome A–Z", + "newest": "Mais recentes", + "results": "{count} apps", + "oneResult": "1 app", + "loading": "Carregando apps…", + "openApp": "Abrir app", + "exploreApp": "Explorar {title}", + "previewAlt": "Prévia de {title}", + "previewUnavailable": "Prévia indisponível", + "loadingPreview": "Carregando prévia…", + "previewCaption": "Layout capturado automaticamente", + "openFullApp": "Abrir app completa", + "backToMarketplace": "Voltar ao marketplace", + "aboutApp": "Sobre esta app", + "createdBy": "Criada por", + "moreFromCreator": "Mais deste criador", + "communityCreator": "Criador da comunidade", + "uncategorized": "Sem categoria", + "workspace": "Espaço de trabalho", + "source": "Marketplace", + "copyLink": "Copiar link", + "linkCopied": "Link copiado", + "noDescription": "O criador ainda não adicionou uma descrição.", + "appUnavailable": "Esta app não está mais disponível", + "appUnavailableDescription": "Ela pode ter sido removida do marketplace ou você não tem mais acesso.", + "noMatches": "Nenhuma app corresponde à busca", + "localEmpty": "Sua coleção local começa aqui", + "localEmptyDescription": "Apps compartilhadas nesta instalação aparecem aqui. Publique uma app em Compartilhar para sua equipe descobri-la.", + "communityEmpty": "Mais inspiração em breve", + "communityEmptyDescription": "Não há apps da comunidade para mostrar agora.", + "goToApps": "Ir para suas apps" +}, + "admin": { + "navigation": "Navegação", + "viewMode": "Visualização", + "listView": "Vista de lista", + "cardView": "Vista de cartões", + "clearFilters": "Limpar filtros", + "noResults": "Sem resultados. Tente outra pesquisa ou limpe os filtros.", + "loadError": "Não foi possível carregar esta lista. Tente novamente.", + "retry": "Tentar novamente", + "resultCount": "{start}–{end} de {total}", + "appsDescription": "Suas apps, módulos e pastas. Crie algo que melhore o trabalho.", + "folderDescription": "Agrupe apps relacionadas. Abra uma pasta para explorar o conteúdo.", + "trashDescription": "Revise as apps excluídas. Restaure o que precisar ou remova permanentemente.", + "marketplaceDescription": "Inspiração para seu próximo projeto. Explore apps criadas com Lowcoder.", + "datasourceDescription": "Conecte bancos de dados e serviços e use-os em seu espaço de trabalho.", + "queriesDescription": "Crie, teste e compartilhe consultas reutilizáveis entre suas apps.", + "newsDescription": "Ideias, novidades e dicas práticas para criar com Lowcoder.", + "profileDescription": "Gerencie seu perfil e suas preferências pessoais.", + "blogPosts": "Do blog", + "videos": "Assista e aprenda", + "releases": "Últimas versões" +}, "automator": { "studio": { "connection": "Conexão", @@ -5165,4 +5234,4 @@ export const pt = { "buySubscriptionButton": "Subscrever agora", }, -}; \ No newline at end of file +}; diff --git a/client/packages/lowcoder/src/i18n/locales/ru.ts b/client/packages/lowcoder/src/i18n/locales/ru.ts index f2cd7c63fa..765b71fe32 100644 --- a/client/packages/lowcoder/src/i18n/locales/ru.ts +++ b/client/packages/lowcoder/src/i18n/locales/ru.ts @@ -2,6 +2,75 @@ import { en } from "./en"; export const ru = { ...en, + "marketplace": { + "eyebrow": "Создано с Lowcoder", + "title": "Найдите вдохновение для новой идеи", + "description": "Изучайте полезные приложения, их возможности и знакомьтесь с авторами.", + "community": "Сообщество", + "local": "Эта установка", + "shareApp": "Поделиться приложением", + "shareHint": "Откройте свое приложение и опубликуйте его через настройки общего доступа.", + "search": "Поиск приложений, идей и авторов", + "allTypes": "Все типы", + "allCategories": "Все категории", + "category": "Категория", + "categories": "Обзор категорий", + "appType": "Тип приложения", + "sort": "Сортировка", + "nameSort": "По названию", + "newest": "Сначала новые", + "results": "Приложений: {count}", + "oneResult": "1 приложение", + "loading": "Загрузка приложений…", + "openApp": "Открыть", + "exploreApp": "Подробнее: {title}", + "previewAlt": "Предпросмотр: {title}", + "previewUnavailable": "Предпросмотр недоступен", + "loadingPreview": "Загрузка предпросмотра…", + "previewCaption": "Автоматический снимок макета", + "openFullApp": "Открыть приложение", + "backToMarketplace": "Назад в маркетплейс", + "aboutApp": "О приложении", + "createdBy": "Автор", + "moreFromCreator": "Другие работы автора", + "communityCreator": "Автор сообщества", + "uncategorized": "Без категории", + "workspace": "Рабочее пространство", + "source": "Маркетплейс", + "copyLink": "Копировать ссылку", + "linkCopied": "Ссылка скопирована", + "noDescription": "Автор пока не добавил описание.", + "appUnavailable": "Приложение больше недоступно", + "appUnavailableDescription": "Возможно, его удалили из маркетплейса или у вас больше нет доступа.", + "noMatches": "Приложения не найдены", + "localEmpty": "Здесь начинается ваша коллекция", + "localEmptyDescription": "Здесь появятся приложения этой установки. Опубликуйте приложение в настройках общего доступа, чтобы команда могла его найти.", + "communityEmpty": "Скоро появятся новые идеи", + "communityEmptyDescription": "Сейчас нет приложений сообщества для отображения.", + "goToApps": "К вашим приложениям" +}, + "admin": { + "navigation": "Навигация", + "viewMode": "Режим просмотра", + "listView": "Список", + "cardView": "Карточки", + "clearFilters": "Сбросить фильтры", + "noResults": "Ничего не найдено. Измените запрос или сбросьте фильтры.", + "loadError": "Не удалось загрузить список. Попробуйте ещё раз.", + "retry": "Повторить", + "resultCount": "{start}–{end} из {total}", + "appsDescription": "Ваши приложения, модули и папки. Создавайте то, что делает работу удобнее.", + "folderDescription": "Объединяйте связанные приложения. Откройте папку, чтобы просмотреть её содержимое.", + "trashDescription": "Просматривайте удалённые приложения. Восстанавливайте нужные или удаляйте окончательно.", + "marketplaceDescription": "Идеи для вашего следующего проекта. Изучайте приложения, созданные с Lowcoder.", + "datasourceDescription": "Подключайте базы данных и сервисы для использования в рабочем пространстве.", + "queriesDescription": "Создавайте, тестируйте и делитесь запросами для повторного использования в приложениях.", + "newsDescription": "Идеи, обновления продукта и практические советы по работе с Lowcoder.", + "profileDescription": "Управляйте своим профилем и личными настройками.", + "blogPosts": "Из блога", + "videos": "Смотрите и учитесь", + "releases": "Последние версии" +}, "automator": { "studio": { "connection": "Подключение", @@ -5165,4 +5234,4 @@ export const ru = { "buySubscriptionButton": "Подписаться сейчас", }, -}; \ No newline at end of file +}; diff --git a/client/packages/lowcoder/src/i18n/locales/zh.ts b/client/packages/lowcoder/src/i18n/locales/zh.ts index 53da8efcc5..dd2f0ee6b7 100644 --- a/client/packages/lowcoder/src/i18n/locales/zh.ts +++ b/client/packages/lowcoder/src/i18n/locales/zh.ts @@ -2,8 +2,77 @@ import { en } from "./en"; export const zh = { - ...en, - "automator": { + ...en, + "marketplace": { + "eyebrow": "使用 Lowcoder 构建", + "title": "寻找下一份灵感", + "description": "探索实用应用,了解其功能,并认识背后的创作者。", + "community": "社区", + "local": "本地实例", + "shareApp": "分享应用", + "shareHint": "打开你的应用,通过分享设置将其发布到市场。", + "search": "搜索应用、创意或创作者", + "allTypes": "所有类型", + "allCategories": "所有分类", + "category": "分类", + "categories": "浏览分类", + "appType": "应用类型", + "sort": "排序", + "nameSort": "按名称排序", + "newest": "最新发布", + "results": "{count} 个应用", + "oneResult": "1 个应用", + "loading": "正在加载应用…", + "openApp": "打开应用", + "exploreApp": "了解 {title}", + "previewAlt": "{title} 的预览", + "previewUnavailable": "预览不可用", + "loadingPreview": "正在加载预览…", + "previewCaption": "自动捕获的布局", + "openFullApp": "打开完整应用", + "backToMarketplace": "返回应用市场", + "aboutApp": "关于此应用", + "createdBy": "创作者", + "moreFromCreator": "查看作者的更多作品", + "communityCreator": "社区创作者", + "uncategorized": "未分类", + "workspace": "工作区", + "source": "应用市场", + "copyLink": "复制详情链接", + "linkCopied": "链接已复制", + "noDescription": "创作者尚未添加描述。", + "appUnavailable": "此应用已不可用", + "appUnavailableDescription": "它可能已从市场移除,或者你已失去访问权限。", + "noMatches": "没有匹配的应用", + "localEmpty": "从这里开始你的本地收藏", + "localEmptyDescription": "本实例分享的应用将在这里显示。通过分享设置发布应用,让团队发现它。", + "communityEmpty": "新的灵感即将到来", + "communityEmptyDescription": "当前没有可展示的社区应用。", + "goToApps": "前往你的应用" +}, + "admin": { + "navigation": "导航", + "viewMode": "视图模式", + "listView": "列表视图", + "cardView": "卡片视图", + "clearFilters": "清除筛选", + "noResults": "没有匹配结果。请尝试其他搜索或清除筛选条件。", + "loadError": "无法加载列表,请重试。", + "retry": "重试", + "resultCount": "第 {start}–{end} 项,共 {total} 项", + "appsDescription": "管理应用、模块和文件夹,构建让工作更轻松的工具。", + "folderDescription": "将相关应用整理在一起,打开文件夹查看内容。", + "trashDescription": "查看已删除的应用,恢复所需应用或将其永久删除。", + "marketplaceDescription": "为下一个项目寻找灵感,探索使用 Lowcoder 构建的应用。", + "datasourceDescription": "连接数据库和服务,并在整个工作空间中使用。", + "queriesDescription": "创建、测试和共享可在应用之间复用的查询。", + "newsDescription": "了解产品动态,获取使用 Lowcoder 构建应用的实用方法。", + "profileDescription": "管理个人资料和个人偏好设置。", + "blogPosts": "博客精选", + "videos": "观看与学习", + "releases": "最新版本" +}, + "automator": { "studio": { "connection": "连接", "connectionDescription": "选择驱动 Automator 的查询,或连接新的 AI 模型。", @@ -5169,4 +5238,4 @@ export const zh = { "buySubscriptionButton": "立即订阅", }, -}; \ No newline at end of file +}; diff --git a/client/packages/lowcoder/src/marketplace-preview.tsx b/client/packages/lowcoder/src/marketplace-preview.tsx new file mode 100644 index 0000000000..9273c662e3 --- /dev/null +++ b/client/packages/lowcoder/src/marketplace-preview.tsx @@ -0,0 +1,131 @@ +// Separate capture entry: no login bootstrap, analytics, application queries or user session. +import "core-js/actual"; +import "regenerator-runtime/runtime"; +import "virtual:globals"; +import "./index.less"; +import React, { useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; +import { MemoryRouter, Route } from "react-router-dom"; +import { ErrorBoundary } from "react-error-boundary"; +import { ConfigProvider } from "antd"; +import { reduxStore } from "redux/store/store"; +import { ReduxActionTypes } from "constants/reduxActionConstants"; +import { ExternalEditorContext } from "util/context/ExternalEditorContext"; +import { setGlobalSettings } from "comps/utils/globalSettings"; +import ubuntuRegular from "assets/fonts/ubuntu-400.woff2"; +import { loaders } from "comps/comps/remoteComp/loaders"; +import { loadComps } from "comps"; +import { RootComp } from "comps/comps/rootComp"; +import { useCompInstance } from "comps/utils/useCompInstance"; +import { ApplicationDetail } from "constants/applicationConstants"; +import "comps/comps/layout/navLayout"; +import "comps/comps/layout/mobileTabLayout"; + +function Capture({ view }: { view: ApplicationDetail }) { + const app = view.applicationInfoView; + const params = useMemo( + () => ({ + Comp: RootComp, + initialValue: view.applicationDSL, + reduceContext: { + applicationId: app.applicationId, + parentApplicationPath: [], + moduleDSL: view.moduleDSL, + readOnly: true, + }, + initHandler: async (comp: RootComp) => { + comp.preloadId = `app-${app.applicationId}`; + await comp.children.preload.children.css.run(comp.preloadId); + await comp.children.preload.children.globalCSS.run(comp.preloadId); + comp.preloaded = true; + return comp; + }, + isReady: true, + }), + [view, app.applicationId], + ); + const [comp] = useCompInstance(params); + const [ready, setReady] = useState(false); + useEffect(() => { + // Wait for the lazy canvas component, rather than capturing its loading skeleton. + const observer = new MutationObserver(() => { + if (document.querySelector("#marketplace-canvas .react-grid-layout")) { + setReady(true); + observer.disconnect(); + } + }); + observer.observe(document.getElementById("marketplace-canvas")!, { + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + }, []); + return ( + + + + + +
+ {comp?.getView()} +
+
+
+
+
+
+ ); +} + +async function render() { + loadComps(); + const loadNpm = loaders.npm; + loaders.npm = (info) => { + if (info.packageName !== "lowcoder-comps") + return Promise.reject( + new Error("External components are unavailable in layout previews"), + ); + return loadNpm({ ...info, appId: "none" }); + }; + const fonts = document.createElement("style"); + fonts.textContent = `@font-face{font-family:Ubuntu;src:url('${ubuntuRegular}')}body{font-family:Ubuntu,Arial,sans-serif;}`; + document.head.appendChild(fonts); + // Fulfilled only inside the capture worker. This is not a public data endpoint. + const response = await fetch("/__marketplace_capture_payload__", { + credentials: "omit", + }); + if (!response.ok) throw new Error("Capture payload unavailable"); + const view: ApplicationDetail = await response.json(); + setGlobalSettings({ + applicationId: view.applicationInfoView.applicationId, + isViewMode: true, + orgCommonSettings: view.orgCommonSettings, + }); + reduxStore.dispatch({ + type: ReduxActionTypes.FETCH_APP_PUBLISH_DETAIL_SUCCESS, + payload: view, + }); + createRoot(document.getElementById("root")!).render( + }> + + , + ); +} +render().catch(() => { + document + .getElementById("root")! + .setAttribute("data-marketplace-capture-error", "true"); +}); diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/CreateDropdown.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/CreateDropdown.tsx index 697800b4dc..d3650180f5 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/CreateDropdown.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/CreateDropdown.tsx @@ -68,10 +68,14 @@ const CreateMenuItem = styled.div` `; const CreateBtn = styled(TacoButton)` - width: 96px; - height: 32px; - padding: 0; - margin-left: 12px; + width: auto; + min-width: 100px; + height: 36px; + padding: 0 16px; + margin-left: 0; + border-radius: 10px; + && { background: #8050a0; border-color: #8050a0; color: white; } + &&:hover { background: #683e86; border-color: #683e86; } & > svg { width: 16px; diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/FolderNavigator.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/FolderNavigator.tsx new file mode 100644 index 0000000000..970e1875e6 --- /dev/null +++ b/client/packages/lowcoder/src/pages/ApplicationV2/FolderNavigator.tsx @@ -0,0 +1,34 @@ +import { TreeSelect } from "antd"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import { useHistory, useParams } from "react-router-dom"; +import { ALL_APPLICATIONS_URL, buildFolderUrl } from "constants/routesURL"; +import { foldersSelector } from "redux/selectors/folderSelector"; +import { buildFolderHierarchy, FolderHierarchyNode } from "util/folderUtils"; +import { trans } from "i18n"; + +interface FolderOption { value: string; title: string; children: FolderOption[] } +const toOptions = (nodes: FolderHierarchyNode[]): FolderOption[] => nodes.map(({ folder, children }) => ({ + value: folder.folderId, title: folder.name, children: toOptions(children), +})); + +export function FolderNavigator() { + const folders = useSelector(foldersSelector); + const { folderId } = useParams<{ folderId?: string }>(); + const history = useHistory(); + const options = useMemo(() => [{ + value: "__all_apps__", title: trans("home.allApplications"), children: toOptions(buildFolderHierarchy(folders)), + }], [folders]); + if (!folders.length) return null; + return history.push(value === "__all_apps__" ? ALL_APPLICATIONS_URL : buildFolderUrl(value))} + />; +} diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx index 8244bcb22f..4cda5491ff 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx @@ -1,7 +1,8 @@ +import { useAdminPagination } from "components/layout/useAdminPagination"; import { useDispatch, useSelector } from "react-redux"; import { useParams } from "react-router-dom"; import { HomeBreadcrumbType, HomeLayout } from "./HomeLayout"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { useDebouncedValue } from "util/hooks"; import {ApplicationCategoriesEnum, ApplicationMeta, FolderMeta} from "../../constants/applicationConstants"; import { buildFolderUrl } from "../../constants/routesURL"; @@ -14,18 +15,8 @@ import { fetchFolderElements as fetchFolderElementsRedux } from "../../redux/red import { getUser } from "../../redux/selectors/usersSelectors"; import { flattenFolderTree, getFolderPath } from "../../util/folderUtils"; -interface ElementsState { - elements: Array; - total: number; -} - export function FolderView() { const { folderId } = useParams<{ folderId: string }>(); - - const [elements, setElements] = useState({ elements: [], total: 0 }); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [searchValues, setSearchValues] = useState(""); const [typeFilter, setTypeFilter] = useState(0); const [modify, setModify] = useState(true); const [searchValue, setSearchValue] = useState(""); @@ -57,62 +48,25 @@ export function FolderView() { } }, [allFolders.length, user.currentOrgId, dispatch]); - useEffect(() => { - setCurrentPage(1); - }, [folderId]); - - useEffect(() => { - let active = true; - - fetchFolderElements({ + const debouncedSearch = useDebouncedValue(searchValue, 300).trim(); + const loadPage = useCallback((page: number, size: number) => fetchFolderElements({ id: folderId, - pageNum: currentPage, - pageSize, + pageNum: page, pageSize: size, name: debouncedSearch, applicationType: ApplicationPaginationType[typeFilter], - name: searchValues, category: categoryFilter === "All" ? "" : categoryFilter, - }).then((data: any) => { - if (!active) { - return; - } - if (data.success) { - setElements({ elements: data.data || [], total: data.total || 0 }); - } else { - console.error("ERROR: fetchFolderElements", data.error); - } - }); - - return () => { - active = false; - }; - }, [ - folderId, - currentPage, - pageSize, - searchValues, - typeFilter, - modify, - categoryFilter, - ]); - - useEffect( () => { - if (searchValues !== "") - setCurrentPage(1); - }, [searchValues] - ); - - const debouncedSearchValue = useDebouncedValue(searchValue, 500); - - useEffect(() => { - if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") - setSearchValues(debouncedSearchValue); - }, [debouncedSearchValue]); + }), [debouncedSearch, typeFilter, modify, categoryFilter, folderId]); + const pagination = useAdminPagination({ + scope: user.currentOrgId ? `${user.currentOrgId}:${folderId}` : "", + filterKey: JSON.stringify([debouncedSearch, typeFilter, categoryFilter]), + loadPage, + }); + const { currentPage, pageSize, setCurrentPage, setPageSize } = pagination; return ( <> {{folder?.name || trans("home.yourFolders")}} .ant-select-selection-item { - font-size: 16px !important; - font-weight: 500; - } - - .ant-select-selector .ant-select-selection-item svg { - margin-top: 7px !important; - } - - .ant-select-item-option-content { - font-size: 13px; - line-height: 13px; - } - - &:is(.ant-select-open) { - .ant-select-selection-item { - color: #315efb; - } - } - - .ant-select-item-option-active:not(.ant-select-item-option-disabled) { - background-color: #f2f7fc; - } - - .ant-select-dropdown { - padding: 8px; - border-radius: 8px; - width: fit-content !important; - } - - .ant-select-item { - height: 29px; - min-height: 0; - padding: 0 8px; - border-radius: 4px; - } - - .ant-select-item-option-selected:not(.ant-select-item-option-disabled) { - font-weight: 400; - color: #4965f2; - background-color: white; - } + min-width: 140px; + max-width: 100%; + .ant-select-selector { border-radius: 8px; font-size: 13px; } `; const FilterMenuItem = styled.div` display: flex; - align-items: left; - height: 29px; - width: 100%; -`; - -const BreadcrumbItem = styled.div<{ $clickable: boolean }>` - cursor: ${(props) => (props.$clickable ? "pointer" : "default")}; + align-items: center; + gap: 4px; `; const SkeletonStyle = styled(Skeleton)` - margin: 80px 36px; - - .ant-skeleton-content .ant-skeleton-paragraph > li { - width: 504px; - display: inline-block; - - &:nth-of-type(2n + 1) { - margin-right: 120px; - } - } - - @media screen and (max-width: 500px) { - .ant-skeleton-content .ant-skeleton-paragraph > li { - max-width: calc(100vw - 72px); - min-width: calc(100vw - 72px); - margin-right: 0; - } - } + padding: 32px; `; const EmptyView = styled.div` font-size: 14px; - color: #8b8fa3; + color: var(--admin-muted); display: flex; flex-direction: column; align-items: center; - flex-grow: 1; - padding-top: 120px; - @media screen and (max-width: 500px) { - > div, - > button { - display: none; - } - } + text-align: center; + gap: 12px; + padding: 56px 24px; `; const PaginationLayout = styled.div` display: flex; - justify-content: center; - margin-top: 20px; - margin-bottom: 20px; -` - -const LayoutSwitcher = styled.div` - position: absolute; - left: 20px; - top: 40px; - cursor: pointer; - width: 32px; - height: 16px; - border-radius: 4px; - z-index: ${Layers.homeLayoutSwitcher}; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - background-color: #f5f5f6; - } + justify-content: flex-end; + padding: 20px 24px; + border-top: 1px solid var(--admin-line); + @media (max-width: 720px) { justify-content: center; padding: 16px; } `; const HomeView = styled.div` - font-size: 14px; - color: #8b8fa3; - flex-grow: 1; - padding-top: 0px; - padding-left: 40px; - max-width: 95%; + min-width: 0; + .admin-resource-surface { border: 1px solid var(--admin-line); border-radius: 16px; } + .admin-resource-surface > .ant-card-body { padding: 0; } `; -const StyleHomeCover = styled.div` - background: rgb(2,0,36); - background: -moz-linear-gradient(121deg, rgba(2,0,36,1) 0%, rgba(23,102,167,1) 42%, rgba(243,130,244,1) 100%); - background: -webkit-linear-gradient(121deg, rgba(2,0,36,1) 0%, rgba(23,102,167,1) 42%, rgba(243,130,244,1) 100%); - background: linear-gradient(121deg, rgba(2,0,36,1) 0%, rgba(23,102,167,1) 42%, rgba(243,130,244,1) 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#020024",endColorstr="#f382f4",GradientType=1); - padding: 25px; - height: 120px; - border-radius:10px 10px 0 0; -`; +const StyleHomeCover = AdminCover; const SearchWrapper = styled.div` - width: auto; - display: flex; align-items: center; - flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; margin-left: auto; - - @media screen and (max-width: 980px) { - width: 100%; - > Button { - display: none; - } - } + @media (max-width: 980px) { margin-left: 0; } + @media (max-width: 520px) { width: 100%; > div:first-child { flex: 1; } } `; function showNewUserGuide(user: User) { @@ -310,10 +180,12 @@ export interface HomeLayoutProps { isCreated?: boolean; setModify?: any; modify?: boolean; + loading?: boolean; + error?: boolean; + onRetry?: () => void; } export function HomeLayout(props: HomeLayoutProps) { - const mounted = useRef(true); const { breadcrumb = [], title, elements = [], @@ -332,68 +204,37 @@ export function HomeLayout(props: HomeLayoutProps) { setModify, modify, setIsCreated, - isCreated + isCreated, + loading = false, + error = false, + onRetry = () => {} } = props; const user = useSelector(getUser); - const isFetching = useSelector(isFetchingFolderElements); + const isFetching = loading; const isSelfHost = window.location.host !== 'app.lowcoder.cloud'; const [typeFilter, setTypeFilter] = useState("All"); const [categoryFilter, setCategoryFilter] = useState("All"); - const [visibility, setVisibility] = useState(mode === "view" || mode === "trash" || mode === "folder"); + const visibility = mode === "view" || mode === "trash" || mode === "folder"; const [layout, setLayout] = useState( checkIsMobile(window.innerWidth) ? "card" : getHomeLayout() ); - // Cleanup on unmount - useEffect(() => { - return () => { - mounted.current = false; - }; - }, []); - - const handlePageChange = useCallback((page: number) => { - if (mounted.current && setCurrentPage) { - setCurrentPage(page); - } - }, [setCurrentPage]); - - const handlePageSizeChange = useCallback((current: number, size: number) => { - if (mounted.current && setPageSize) { - setPageSize(size); - } - }, [setPageSize]); - - const handleTypeFilterChange = useCallback((value: any) => { - if (mounted.current) { - setTypeFilter(value as HomeResKey); - if (visibility && setTypeFilterPagination) { - setTypeFilterPagination(HomeResTypeEnum[value]); - } - } - }, [visibility, setTypeFilterPagination]); - - const handleCategoryFilterChange = useCallback((value: any) => { - if (mounted.current) { - setCategoryFilter(value as ApplicationCategoriesEnum); - if (setCategoryFilterPagination) { - setCategoryFilterPagination(value as ApplicationCategoriesEnum); - } - } - }, [setCategoryFilterPagination]); - - const handleSearchChange = useCallback((e: React.ChangeEvent) => { - if (mounted.current && setSearchValue) { - setSearchValue(e.target.value); - } - }, [setSearchValue]); - - const handleLayoutChange = useCallback(() => { - if (mounted.current) { - setLayout(layout === "list" ? "card" : "list"); - } - }, [layout]); - + const handleTypeFilterChange = (value: any) => { + setTypeFilter(value as HomeResKey); + setTypeFilterPagination?.(HomeResTypeEnum[value]); + }; + const handleCategoryFilterChange = (value: any) => { + setCategoryFilter(value); + setCategoryFilterPagination?.(value); + }; + const handleSearchChange = (e: React.ChangeEvent) => setSearchValue?.(e.target.value); + const hasFilters = Boolean(searchValue?.trim()) || typeFilter !== "All" || categoryFilter !== "All"; + const clearFilters = () => { + setSearchValue?.(""); + handleTypeFilterChange("All"); + handleCategoryFilterChange("All"); + }; useEffect(() => saveHomeLayout(layout), [layout]); @@ -455,6 +296,7 @@ export function HomeLayout(props: HomeLayoutProps) { } return true; }) + .filter((e) => visibility || categoryFilter === "All" || (!e.folder && e.category === categoryFilter)) .map((e) => e.folder ? { @@ -485,7 +327,7 @@ export function HomeLayout(props: HomeLayoutProps) { isLocalMarketplace: e.isLocalMarketplace, } ); - }, [displayElements, visibility, searchValue, typeFilter, mode, user]); + }, [displayElements, visibility, searchValue, typeFilter, categoryFilter, mode, user]); const getFilterMenuItem = useCallback((type: HomeResTypeEnum) => { const Icon = HomeResInfo[type].icon; @@ -525,7 +367,7 @@ export function HomeLayout(props: HomeLayoutProps) { getFilterMenuItem(HomeResTypeEnum.Application), getFilterMenuItem(HomeResTypeEnum.Module), ...(mode !== "marketplace" ? [getFilterMenuItem(HomeResTypeEnum.Navigation), getFilterMenuItem(HomeResTypeEnum.MobileTabLayout)] : []), - ...(mode !== "trash" && mode !== "marketplace" && mode !== "folder" ? [getFilterMenuItem(HomeResTypeEnum.Folder)] : []), + ...(mode !== "trash" && mode !== "marketplace" ? [getFilterMenuItem(HomeResTypeEnum.Folder)] : []), ], [mode, getFilterMenuItem]); const localMarketplaceAppsList = useMemo(() => { @@ -554,7 +396,7 @@ export function HomeLayout(props: HomeLayoutProps) { } return ( - + } @@ -562,25 +404,21 @@ export function HomeLayout(props: HomeLayoutProps) { itemRender={(item) => { const isCurrent = item.key === breadcrumbItems[breadcrumbItems.length - 1]?.key; return ( - - {item.title} - + isCurrent ? {item.title} : + { event.preventDefault(); item.onClick?.(event); }}>{item.title} ); }} > + {(mode === "view" || mode === "folder") && } {showNewUserGuide(user) && } -

+

{mode === "marketplace" && trans("home.appMarketplace")} {mode === "folders" && trans("home.allFolders")} {mode === "folder" && (title || trans("home.folder"))} @@ -588,13 +426,15 @@ export function HomeLayout(props: HomeLayoutProps) { {mode === "trash" && trans("home.trash")} {mode === "view" && trans("home.allApplications")}

+

{trans(mode === "marketplace" ? "admin.marketplaceDescription" : mode === "trash" ? "admin.trashDescription" : mode === "folder" ? "admin.folderDescription" : "admin.appsDescription")}

- + {mode !== "folders" && mode !== "module" && ( } />} {mode === "marketplace" && ( handleCategoryFilterChange(value as ApplicationCategoriesEnum)} options={categoryOptions} suffixIcon={} /> )} - - {layout === "list" ? : } - + {mode !== "trash" && setLayout(value as HomeLayoutType)} + options={[ + { value: "list", label: }, + { value: "card", label: }, + ]} + />} - + {hasFilters &&
} + - + - {isFetching && resList.length === 0 ? ( - + {error &&
} + {isFetching ? ( + ) : ( <> {resList.length > 0 ? ( @@ -717,11 +568,11 @@ export function HomeLayout(props: HomeLayoutProps) { )} - ) : ( + ) : !error && (
- {mode === "trash" + {hasFilters ? trans("admin.noResults") : mode === "trash" ? trans("home.trashEmpty") : mode === "marketplace" ? trans("home.noMarketplaceApps") @@ -729,19 +580,23 @@ export function HomeLayout(props: HomeLayoutProps) { ? trans("home.projectEmptyCanAdd") : trans("home.projectEmpty")}
- {mode !== "trash" && mode !== "marketplace" && user.orgDev && } + {!hasFilters && mode !== "trash" && mode !== "marketplace" && user.orgDev && }
)} )}
- {visibility && resList.length ?
+ {visibility && !error && (total ?? 0) > 0 ?
{ + if (size !== pageSize) setPageSize?.(size); + else setCurrentPage?.(page); + }} + showTotal={(count, range) => trans("admin.resultCount", { start: range[0], end: range[1], total: count })} + responsive total={total} showSizeChanger /> diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx index 43ac626d5a..a6a182a631 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx @@ -35,12 +35,12 @@ const ExecButton = styled(TacoButton)` width: 52px; height: 24px; padding: 5px 12px; - margin-right: 24px; - background: #fafbff; - border: 1px solid #c9d1fc; + margin-right: 10px; + background: #f8f1fc; + border: 1px solid #e5d4f0; border-radius: 4px; font-weight: 500; - color: #4965f2; + color: #754394; &:hover { background: #f9fbff; @@ -50,26 +50,23 @@ const ExecButton = styled(TacoButton)` @media screen and (max-width: 500px) { margin-right: 0; - display: none; } `; const Wrapper = styled.div` - padding: 0 6px; - border-radius: 8px; - margin-bottom: 2px; - margin-top: 2px; - padding-top: 10px; - padding-bottom: 10px; - background-color: #fcfcfc; - min-height: 100px; - &:hover { - background-color: #f5f5f6 - } + border: 1px solid var(--admin-line, #e9e0ee); + border-radius: 12px; + padding: 18px 12px; + background: white; + min-height: 116px; + transition: border-color 150ms, box-shadow 150ms; + &:hover, &:focus-within { border-color: #c7a8dc; box-shadow: 0 6px 20px #683e860a; } `; const Card = styled.div` - display: flex; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 14px 8px; align-items: center; height: 100%; width: 100%; @@ -77,7 +74,7 @@ const Card = styled.div` padding: 0 10px; button { - opacity: 0; + opacity: 1; } &:hover { @@ -92,23 +89,32 @@ const Card = styled.div` } padding: 0; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 14px 8px; } `; const CardInfo = styled.div` + min-width: 0; display: flex; flex-direction: column; justify-content: center; - margin-left: 14px; - white-space: nowrap; - width: 30%; + margin-left: 0; + white-space: normal; + width: auto; height: 100%; flex-grow: 1; cursor: pointer; - padding-right: 12px; + padding-right: 0; .ant-typography { padding: 2px 2px 8px 2px; + white-space: normal; overflow-wrap: anywhere; line-height: 1.4; + } + @media (max-width: 500px) { + width: auto; margin-left: 0; padding-right: 0; min-height: 0; + .ant-typography { white-space: normal; overflow-wrap: anywhere; line-height: 1.4; } } `; @@ -123,13 +129,12 @@ const AppTimeOwnerInfoLabel = styled.div` const OperationWrapper = styled.div` display: flex; + grid-column: 1 / -1; + justify-content: flex-end; align-items: center; padding-right: 10px; - @media screen and (max-width: 500px) { - > svg { - display: none; - } - } + + @media (max-width: 500px) { grid-column: 1 / -1; justify-content: flex-end; padding-right: 0; gap: 8px; } `; export const StyledTypographyText = styled(AntdTypographyText)` @@ -295,6 +300,9 @@ export function HomeResCard(props: { res: HomeRes; onMove: (res: HomeRes) => voi ) } { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.currentTarget.click(); } }} onClick={(e) => { if (res.type === HomeResTypeEnum.Folder) { handleFolderViewClick(res.id); @@ -368,6 +376,9 @@ export function Back(props: { mode: string; parentPath?: string; parentLabel?: s } } /> { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.currentTarget.click(); } }} onClick={() => { backFolderViewClick(parentPath); }} diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/HomeTableView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/HomeTableView.tsx index f45f85275f..2fb4d06ba4 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/HomeTableView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/HomeTableView.tsx @@ -44,7 +44,7 @@ const SubColumnCell = styled.div` `; const EditBtn = styled(TacoButton)` - opacity: 0; + opacity: 1; width: 52px; height: 24px; `; @@ -74,11 +74,11 @@ export const HomeTableView = (props: { resources: HomeRes[], setModify?: any, mo const handleModalOk = (values: any) => { if (currentRes) { - currentRes.type === HomeResTypeEnum.Folder && - dispatch(updateFolder({ id: currentRes.id, name: values.appName || currentRes.name })) - dispatch( - updateAppMetaAction({ applicationId: currentRes.id, name: values.appName || currentRes.name, folderId: folderId }) - ); + if (currentRes.type === HomeResTypeEnum.Folder) { + dispatch(updateFolder({ id: currentRes.id, name: values.appName || currentRes.name })); + } else { + dispatch(updateAppMetaAction({ applicationId: currentRes.id, name: values.appName || currentRes.name, folderId })); + } setUpdateModalVisible(false); setTimeout(() => { @@ -104,11 +104,17 @@ export const HomeTableView = (props: { resources: HomeRes[], setModify?: any, mo />} ({ + tabIndex: 0, + onKeyDown: (event) => { + if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); event.currentTarget.click(); + } + }, onClick: (e) => { if ((record as HomeRes).id === back.id) { backFolderViewClick(parentPath) @@ -120,7 +126,7 @@ export const HomeTableView = (props: { resources: HomeRes[], setModify?: any, mo if (item.type === HomeResTypeEnum.Folder) { handleFolderViewClick(item.id); } else if(item.isMarketplace) { - handleMarketplaceAppViewClick(item.id); + handleMarketplaceAppViewClick(item.id, item.isLocalMarketplace); } else { item.isEditable ? handleAppEditClick(e, item.id) : handleAppViewClick(item.id); } @@ -190,12 +196,7 @@ export const HomeTableView = (props: { resources: HomeRes[], setModify?: any, mo title: trans("home.creator"), dataIndex: "creator", ellipsis: true, - sorter: (a: any, b: any) => { - if (a.creator === b.creator) { - return 0; - } - return a.type > b.type ? 1 : -1; - }, + sorter: (a: any, b: any) => (a.creator ?? "").localeCompare(b.creator ?? ""), render: (text) => {text}, }, { @@ -203,12 +204,7 @@ export const HomeTableView = (props: { resources: HomeRes[], setModify?: any, mo dataIndex: "description", ellipsis: true, width: "250px", - sorter: (a: any, b: any) => { - if (a.creator === b.creator) { - return 0; - } - return a.type > b.type ? 1 : -1; - }, + sorter: (a: any, b: any) => (a.description ?? "").localeCompare(b.description ?? ""), render: (text) => {text}, }, { @@ -231,7 +227,8 @@ export const HomeTableView = (props: { resources: HomeRes[], setModify?: any, mo { title: " ", dataIndex: "operation", - width: "298px", + fixed: "right", + width: "180px", render: (text, record) => { const item = record as HomeRes; return ( @@ -254,10 +251,10 @@ export const HomeTableView = (props: { resources: HomeRes[], setModify?: any, mo return item.type === HomeResTypeEnum.Folder ? handleFolderViewClick(item.id) : item.isMarketplace - ? handleMarketplaceAppViewClick(item.id) + ? handleMarketplaceAppViewClick(item.id, item.isLocalMarketplace) : handleAppViewClick(item.id); }} - style={{ marginRight: "52px", display: item.id === back.id ? "none" : "block" }} + style={{ marginRight: "12px", display: item.id === back.id ? "none" : "block" }} > {trans("view")} diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx index a493555411..442f7cd3f2 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx @@ -1,84 +1,50 @@ +import { useAdminPagination } from "components/layout/useAdminPagination"; import { useSelector } from "react-redux"; import { HomeLayout } from "./HomeLayout"; import { getUser } from "../../redux/selectors/usersSelectors"; import { Helmet } from "react-helmet"; import { trans } from "i18n"; -import { useState, useEffect } from "react"; +import { useState, useCallback } from "react"; import { useDebouncedValue } from "util/hooks"; import {fetchFolderElements} from "@lowcoder-ee/util/pagination/axios"; import {ApplicationCategoriesEnum, ApplicationMeta, FolderMeta} from "@lowcoder-ee/constants/applicationConstants"; import {ApplicationPaginationType} from "@lowcoder-ee/util/pagination/type"; -interface ElementsState { - elements: (ApplicationMeta | FolderMeta)[]; - total: number; -} - export function HomeView() { - const [elements, setElements] = useState({ elements: [], total: 1 }); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); const [searchValue, setSearchValue] = useState(""); - const [searchValues, setSearchValues] = useState(""); const [typeFilter, setTypeFilter] = useState(0); const [modify, setModify] = useState(true); const [isCreated, setIsCreated] = useState(true); const [categoryFilter, setCategoryFilter] = useState("All"); - useEffect( () => { - try{ - fetchFolderElements({ - pageNum:currentPage, - pageSize:pageSize, - applicationType: ApplicationPaginationType[typeFilter], - name: searchValues, - category: categoryFilter === "All" ? "" : categoryFilter - }).then( - (data: any) => { - if (data.success) { - setElements({elements: data.data || [], total: data.total || 1}) - } - else - console.error("ERROR: fetchFolderElements", data.error) - } - ); - } catch (error) { - console.error('Failed to fetch data:', error); - } - }, [currentPage, pageSize, searchValues, typeFilter, modify, categoryFilter, isCreated] - ); - - useEffect( () => { - if (searchValues !== "") - setCurrentPage(1); - }, [searchValues] - ); - - const debouncedSearchValue = useDebouncedValue(searchValue, 500); - - useEffect(() => { - if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { - setSearchValues(debouncedSearchValue); - } - }, [debouncedSearchValue]); - const user = useSelector(getUser); - - if (!user.currentOrgId) { - return null; - } + const debouncedSearch = useDebouncedValue(searchValue, 300).trim(); + const loadPage = useCallback((page: number, size: number) => fetchFolderElements({ + pageNum: page, pageSize: size, name: debouncedSearch, + applicationType: ApplicationPaginationType[typeFilter], + category: categoryFilter === "All" ? "" : categoryFilter, + }), [debouncedSearch, typeFilter, modify, categoryFilter, isCreated]); + const pagination = useAdminPagination({ + scope: user.currentOrgId, + filterKey: JSON.stringify([debouncedSearch, typeFilter, categoryFilter]), + loadPage, + }); + const { currentPage, pageSize, setCurrentPage, setPageSize } = pagination; return ( <> {{trans("productName")} {trans("home.home")}} svg { - display: none; + display: inline-flex; } } + @media (max-width: 500px) { grid-column: 1 / -1; justify-content: flex-end; padding-right: 0; gap: 8px; } `; const MONTH_MILLIS = 30 * 24 * 60 * 60 * 1000; @@ -157,6 +172,8 @@ export function MarketplaceResCard(props: { res: HomeRes; }) { { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.currentTarget.click(); } }} onClick={(e) => { if(res.isMarketplace) { handleMarketplaceAppViewClick(res.id , res.isLocalMarketplace); diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/MarketplaceView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/MarketplaceView.tsx index 185c2b18b9..03411343f1 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/MarketplaceView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/MarketplaceView.tsx @@ -1,70 +1,702 @@ -import { useEffect, useState } from "react"; -import { HomeLayout } from "./HomeLayout"; -import { MARKETPLACE_URL } from "constants/routesURL"; -import { trans } from "../../i18n"; -import axios, { AxiosResponse } from "axios"; -import ApplicationApi from "@lowcoder-ee/api/applicationApi"; -import { ApplicationMeta } from "@lowcoder-ee/constants/applicationConstants"; -import { GenericApiResponse } from "@lowcoder-ee/api/apiResponses"; -import { validateResponse } from "@lowcoder-ee/api/apiUtils"; -import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + Button, + Input, + Pagination, + Select, + Skeleton, + Tooltip, +} from "antd"; +import { + AppstoreOutlined, + ArrowLeftOutlined, + ArrowUpOutlined, + GlobalOutlined, + LinkOutlined, + SearchOutlined, + UnorderedListOutlined, +} from "@ant-design/icons"; +import { Link, useHistory, useLocation, useRouteMatch } from "react-router-dom"; import { Helmet } from "react-helmet"; +import axios from "axios"; +import copy from "copy-to-clipboard"; +import { useSelector } from "react-redux"; +import { getUser } from "redux/selectors/usersSelectors"; +import ApplicationApi from "api/applicationApi"; +import { trans } from "i18n"; +import { APPLICATION_MARKETPLACE_URL } from "constants/routesURL"; +import { AppTypeEnum } from "constants/applicationConstants"; +import { HomeResInfo } from "util/homeResUtils"; +import { HomeResTypeEnum } from "types/homeRes"; +import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { MarketplacePreview } from "./marketplace/MarketplacePreview"; +import { + MarketplaceGrid, + MarketplaceShell, +} from "./marketplace/marketplaceStyles"; +import { + appCategory, + appCreator, + appTitle, + filterMarketplaceApps, + MarketplaceApp, + marketplaceAppUrl, + marketplaceCategories, + MarketplaceSource, +} from "./marketplace/marketplaceUtils"; + +type Feed = { apps: MarketplaceApp[]; loading: boolean; error: boolean }; +const initialFeed: Feed = { apps: [], loading: true, error: false }; +const typeName = (app: MarketplaceApp) => + HomeResInfo[app.applicationType as unknown as HomeResTypeEnum]?.name || + trans("home.app"); +const creatorName = (app: MarketplaceApp) => + appCreator(app) || trans("marketplace.communityCreator"); +const initials = (name: string) => + name + .trim() + .split(/[\s@._-]+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); export function MarketplaceView() { - const [ marketplaceApps, setMarketplaceApps ] = useState>([]); - const [ localMarketplaceApps, setLocalMarketplaceApps ] = useState>([]); - const [searchValue, setSearchValue] = useState(""); + const workspaceId = useSelector(getUser).currentOrgId; + const history = useHistory(); + const location = useLocation(); + const detail = useRouteMatch<{ source: string; appId: string }>( + "/marketplace/:source/:appId", + ); + const params = new URLSearchParams(location.search); + const communityOrigin = new URL(APPLICATION_MARKETPLACE_URL).origin; + const communityApiOrigin = new URL(trans("home.marketplaceURL")).origin; + const isCommunityHost = window.location.origin === communityOrigin; + const source: MarketplaceSource = + (detail?.params.source || params.get("source")) === "local" && + !isCommunityHost + ? "local" + : "community"; + const [local, setLocal] = useState(initialFeed); + const [community, setCommunity] = useState(initialFeed); + const [revision, setRevision] = useState(0); + const feed = source === "local" || isCommunityHost ? local : community; + const query = params.get("q") || ""; + const category = params.get("category") || ""; + const type = params.get("type") || ""; + const creator = params.get("creator") || ""; + const sort = params.get("sort") === "newest" ? "newest" : "name"; + const list = params.get("view") === "list"; + const requestedPage = Math.max(1, Number(params.get("page")) || 1); + const pageSize = [12, 24, 48].includes(Number(params.get("size"))) + ? Number(params.get("size")) + : 12; + const [searchInput, setSearchInput] = useState(query); + const shellRef = useRef(null); + const scrollPositions = useRef(new Map()); + const scrollParams = new URLSearchParams(location.search); + source === "local" + ? scrollParams.set("source", "local") + : scrollParams.delete("source"); + scrollParams.sort(); + const scrollKey = `${location.pathname}?${scrollParams}`; - const fetchMarketplaceApps = async () => { - try { - let response: AxiosResponse>; - const http = axios.create({ - baseURL: trans("home.marketplaceURL"), - withCredentials: false, - }); - response = await http.get(`/api/v1/applications/marketplace-apps`); - const isValidResponse: boolean = validateResponse(response); - if (isValidResponse) { - setMarketplaceApps(response.data.data); - } - } catch (error: any) { - messageInstance.error(trans("home.errorMarketplaceApps")); - } - } + useLayoutEffect(() => { + const container = shellRef.current?.closest("main"); + if (!container || feed.loading) return; + container.scrollTop = scrollPositions.current.get(scrollKey) || 0; + const positions = scrollPositions.current; + const rememberPosition = () => { + if (positions.size >= 100) + positions.delete(positions.keys().next().value!); + positions.set(scrollKey, container.scrollTop); + }; + container.addEventListener("scroll", rememberPosition, { passive: true }); + return () => container.removeEventListener("scroll", rememberPosition); + }, [scrollKey, feed.loading]); - const fetchLocalMarketplaceApps = async () => { - try { - let response: AxiosResponse>; - response = await ApplicationApi.fetchAllMarketplaceApps(); - const isValidResponse: boolean = validateResponse(response); - if (isValidResponse) { - setLocalMarketplaceApps(response.data.data); - } - } catch (error: any) { - messageInstance.error(trans("home.errorMarketplaceApps")); - } - } + useEffect(() => { + setSearchInput(query); + }, [query]); + useEffect(() => { + if (searchInput === query) return; + const timer = setTimeout(() => { + const next = new URLSearchParams(location.search); + searchInput ? next.set("q", searchInput) : next.delete("q"); + next.delete("page"); + history.replace({ pathname: "/marketplace", search: next.toString() }); + }, 250); + return () => clearTimeout(timer); + }, [searchInput, query, history, location.search]); useEffect(() => { - // Make sure we are fetching local marketplace apps for self-hosted environments - if (window.location.host !== 'app.lowcoder.cloud') { - fetchMarketplaceApps(); - } - fetchLocalMarketplaceApps(); - }, []); + let active = true; + setLocal(initialFeed); + setCommunity(initialFeed); + const read = async ( + request: Promise, + update: (feed: Feed) => void, + ) => { + try { + const response = await request; + if (!response.data?.success || !Array.isArray(response.data.data)) + throw new Error("Marketplace unavailable"); + if (active) + update({ apps: response.data.data, loading: false, error: false }); + } catch { + if (active) update({ apps: [], loading: false, error: true }); + } + }; + read(ApplicationApi.fetchAllMarketplaceApps(), setLocal); + if (!isCommunityHost) + read( + axios.get( + `${communityApiOrigin}/api/v1/applications/marketplace-apps`, + { withCredentials: false }, + ), + setCommunity, + ); + return () => { + active = false; + }; + }, [workspaceId, revision, communityApiOrigin, isCommunityHost]); - return ( + const apps = useMemo( + () => + filterMarketplaceApps(feed.apps, { + query, + category, + type, + creator, + sort, + }), + [feed.apps, query, category, type, creator, sort], + ); + const categories = useMemo( + () => marketplaceCategories(feed.apps), + [feed.apps], + ); + const page = Math.min( + Math.floor(requestedPage), + Math.max(1, Math.ceil(apps.length / pageSize)), + ); + const visibleApps = apps.slice((page - 1) * pageSize, page * pageSize); + const selected = + detail && + feed.apps.find((app) => app.applicationId === detail.params.appId); + const backParams = new URLSearchParams(location.search); + source === "local" + ? backParams.set("source", "local") + : backParams.delete("source"); + const backUrl = `/marketplace${backParams.toString() ? `?${backParams}` : ""}`; + const actualSource: MarketplaceSource = isCommunityHost ? "local" : source; + const appUrl = (app: MarketplaceApp) => + marketplaceAppUrl(app.applicationId, actualSource, communityOrigin); + const detailUrl = (app: MarketplaceApp) => + `/marketplace/${source}/${encodeURIComponent(app.applicationId)}${location.search}`; + const updateFilters = (changes: Record, replace = false) => { + const next = new URLSearchParams(location.search); + if (detail) next.set("source", source); + next.delete("page"); + Object.entries(changes).forEach(([key, value]) => + value ? next.set(key, value) : next.delete(key), + ); + history[replace ? "replace" : "push"]({ + pathname: "/marketplace", + search: next.toString(), + }); + }; + const clearFilters = () => + updateFilters({ q: "", category: "", type: "", creator: "" }); + const filtered = Boolean(query || category || type || creator); + const creatorApps = (app: MarketplaceApp) => + updateFilters({ creator: app.createBy, q: "", category: "", type: "" }); + const creatorDisplay = (app: MarketplaceApp) => ( <> - {{trans("home.marketplace")}} - + + {creatorName(app)} ); -}; \ No newline at end of file + const tags = (app: MarketplaceApp) => ( +
+ {typeName(app)} + {appCategory(app) && {appCategory(app)}} +
+ ); + const openApp = (app: MarketplaceApp, primary = false) => ( + + ); + + return ( + + + + {selected + ? `${appTitle(selected)} · ${trans("home.marketplace")}` + : trans("home.marketplace")} + + + {detail ? ( + <> + + + {trans("marketplace.backToMarketplace")} + + {feed.loading ? ( + + ) : selected ? ( + <> +
+
+
+ {trans( + source === "community" + ? "marketplace.community" + : "marketplace.local", + )} +
+

{appTitle(selected)}

+
{tags(selected)}
+
+ {openApp(selected, true)} +
+
+
+
+ +
+ {trans("marketplace.previewCaption")} + + {trans("marketplace.openFullApp")} ↗ + +
+
+
+

{trans("marketplace.aboutApp")}

+

+ {selected.description?.trim() || + trans("marketplace.noDescription")} +

+
+
+ +
+ + ) : ( +
+

+ {trans( + feed.error ? "admin.loadError" : "marketplace.appUnavailable", + )} +

+

{trans("marketplace.appUnavailableDescription")}

+ {feed.error && ( + + )} +
+ )} + + ) : ( + <> +
+
+
{trans("marketplace.eyebrow")}
+

{trans("marketplace.title")}

+

{trans("marketplace.description")}

+
+ + + +
+
+ {( + [ + "community", + ...(!isCommunityHost ? ["local"] : []), + ] as MarketplaceSource[] + ).map((tab) => { + const data = + tab === "community" && !isCommunityHost ? community : local; + return ( + + ); + })} +
+
+ + } + value={searchInput} + onChange={(event) => setSearchInput(event.target.value)} + allowClear + /> + updateFilters({ category: value })} + style={{ minWidth: 190 }} + options={[ + { value: "", label: trans("marketplace.allCategories") }, + ...categories.map((item) => ({ + value: item.value, + label: `${item.value} (${item.count})`, + })), + ]} + /> +
+ {categories.length > 0 && ( +
+ + {categories + .slice() + .sort((a, b) => b.count - a.count) + .slice(0, 5) + .map((item) => ( + + ))} +
+ )} +
+ + {feed.loading + ? trans("marketplace.loading") + : apps.length === 1 + ? trans("marketplace.oneResult") + : trans("marketplace.results", { count: apps.length })} + {creator && + ` · ${creatorName(feed.apps.find((app) => app.createBy === creator) || ({ creatorEmail: "" } as MarketplaceApp))}`} + +
+ {filtered && ( + + )} +
{ - if (a.creator === b.creator) { - return 0; - } - return a.type > b.type ? 1 : -1; - }, + sorter: (a: any, b: any) => (a.creator ?? "").localeCompare(b.creator ?? ""), }, { title: trans("home.deleteTime"), @@ -113,7 +108,8 @@ export const TrashTableView = (props: { resources: HomeRes[] , setModify: any, m { title: " ", dataIndex: "operation", - width: "298px", + fixed: "right", + width: "180px", render: (text, record) => { const item = record as HomeRes; return ( diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx index 3b6e843903..da49613b03 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx @@ -1,72 +1,48 @@ +import { useSelector } from "react-redux"; +import { getUser } from "redux/selectors/usersSelectors"; +import { ApplicationMeta } from "constants/applicationConstants"; +import { useAdminPagination } from "components/layout/useAdminPagination"; import { HomeLayout } from "./HomeLayout"; import { TRASH_URL } from "../../constants/routesURL"; -import { useEffect, useState } from "react"; +import { useState, useCallback } from "react"; import { useDebouncedValue } from "util/hooks"; import { trans } from "../../i18n"; import { Helmet } from "react-helmet"; import {fetchApplicationElements} from "@lowcoder-ee/util/pagination/axios"; -interface ElementsState { - elements: any; - total: number; -} - export function TrashView() { - const [elements, setElements] = useState({ elements: [], total: 1 }); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [searchValues, setSearchValues] = useState(""); const [searchValue, setSearchValue] = useState(""); const [typeFilter, setTypeFilter] = useState(0); const [modify, setModify] = useState(false); - useEffect( () => { - try{ - fetchApplicationElements({ - pageNum:currentPage, - pageSize:pageSize, - applicationType: typeFilter === 7 ? 3 : typeFilter, // // Application of Navigation is 3 in API. - name: searchValues, - }).then( - data => { - if (data.success) { - setElements({elements: data.data || [], total: data.total || 1}) - } - else - console.error("ERROR: fetchFolderElements", data.error) - } - ); - } catch (error) { - console.error('Failed to fetch data:', error); - } - }, [currentPage, pageSize, searchValues, typeFilter, modify] - ); - useEffect( () => { - if (searchValues !== "") - setCurrentPage(1); - }, [searchValues] - ); - - const debouncedSearchValue = useDebouncedValue(searchValue, 500); - - useEffect(() => { - if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { - setSearchValues(debouncedSearchValue); - } - }, [debouncedSearchValue]); + const user = useSelector(getUser); + const debouncedSearch = useDebouncedValue(searchValue, 300).trim(); + const loadPage = useCallback((page: number, size: number) => fetchApplicationElements({ + pageNum: page, pageSize: size, name: debouncedSearch, + applicationType: typeFilter === 7 ? 3 : typeFilter, + }), [debouncedSearch, typeFilter, modify]); + const pagination = useAdminPagination({ + scope: user.currentOrgId, + filterKey: JSON.stringify([debouncedSearch, typeFilter]), + loadPage, + }); + const { currentPage, pageSize, setCurrentPage, setPageSize } = pagination; return ( <> {{trans("home.trash")}} - - +

{trans("home.profile")}

{trans("admin.profileDescription")}

-
+ {user.avatarUrl ? ( ) : ( @@ -260,8 +229,8 @@ export function UserProfileLayout(props: UserProfileLayoutProps) { )} - - + +
{user.username} @@ -347,9 +316,9 @@ export function UserProfileLayout(props: UserProfileLayoutProps) { {trans("profile.info")} - + -
+ history.replace(ALL_APPLICATIONS_URL)}>{trans("home.show")}}>
@@ -371,7 +340,7 @@ export function UserProfileLayout(props: UserProfileLayoutProps) {
-
+ history.replace(ALL_APPLICATIONS_URL)}>{trans("home.show")}}>
@@ -394,7 +363,7 @@ export function UserProfileLayout(props: UserProfileLayoutProps) { -
+ history.replace(ALL_APPLICATIONS_URL)}>{trans("home.show")}}>
@@ -416,7 +385,7 @@ export function UserProfileLayout(props: UserProfileLayoutProps) {
-
+ history.replace(ALL_APPLICATIONS_URL)}>{trans("home.show")}}>
diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx index 20ba7f5ab7..2ca5574297 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx @@ -40,6 +40,7 @@ import { clearStyleEval, evalStyle } from "lowcoder-core"; import { QueryLibraryEditor } from "../queryLibrary/QueryLibraryEditor"; import { ProductLoading } from "components/ProductLoading"; import { Layout } from "../../components/layout/Layout"; +import { AdminTheme } from "components/layout/adminTheme"; import { HomeView } from "./HomeView"; import { UserProfileView } from "./UserProfileView"; import { NewsView } from "./NewsView"; @@ -71,17 +72,6 @@ const TabLabel = styled.div` font-weight: 500; `; -const DivStyled = styled.div` - @media screen and (max-width: 500px) { - .ant-layout-sider { - visibility: hidden; - padding: 0; - max-width: 0 !important; - min-width: 0 !important; - } - } -`; - export default function ApplicationHome() { const dispatch = useDispatch(); const [isPreloadCompleted, setIsPreloadCompleted] = useState(false); @@ -129,7 +119,7 @@ export default function ApplicationHome() { } return ( - + {/* */} {/* */} @@ -149,7 +139,6 @@ export default function ApplicationHome() { routeComp: NewsView, icon: ({ selected, ...otherProps }) => selected ? : , visible: ({ user }) => user.orgDev, - style: { color: "red" }, }, { text: {trans("home.orgHome")}, @@ -191,7 +180,6 @@ export default function ApplicationHome() { icon: ({ selected, ...otherProps }) => selected ? : , visible: ({ user }) => user.orgDev, onSelected: (_, currentPath) => currentPath.split("/")[1] === "datasource", - mobileVisible: false, }, { text: {trans("home.queryLibrary")}, @@ -199,7 +187,6 @@ export default function ApplicationHome() { routeComp: QueryLibraryEditor, icon: ({ selected, ...otherProps }) => selected ? : , visible: ({ user }) => user.orgDev, - mobileVisible: false, } ], }, @@ -229,7 +216,6 @@ export default function ApplicationHome() { icon: ({ selected, ...otherProps }) => , mobileVisible: true, visible: () => !isLicenseActive, - style: { color: "#ff6f3c" }, } ] }, @@ -277,6 +263,6 @@ export default function ApplicationHome() { {/* */} {/* */} - + ); } diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/MarketplacePreview.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/MarketplacePreview.tsx new file mode 100644 index 0000000000..68f7822b11 --- /dev/null +++ b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/MarketplacePreview.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { AppstoreOutlined, PictureOutlined } from "@ant-design/icons"; +import { trans } from "i18n"; +import styled from "styled-components"; +import { APPLICATION_MARKETPLACE_URL } from "constants/routesURL"; +import { MarketplaceSource } from "./marketplaceUtils"; + +const Frame = styled.div` + position: relative; + width: 100%; + aspect-ratio: 16 / 10; + overflow: hidden; + background: linear-gradient(145deg, #f4ecfa, #ece7f5); + border-bottom: 1px solid var(--admin-line); + display: flex; + align-items: center; + justify-content: center; + img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: top; + } + .preview-placeholder { + display: flex; + align-items: center; + flex-direction: column; + gap: 14px; + color: #8b709b; + padding: 20px; + text-align: center; + font-size: 12px; + } + .preview-placeholder > .anticon { + font-size: 34px; + padding: 18px; + border: 1px solid #fff; + background: #ffffff75; + border-radius: 20px; + } +`; + +export function MarketplacePreview({ + appId, + source, + title, +}: { + appId: string; + source: MarketplaceSource; + title: string; +}) { + const communityHost = new URL(APPLICATION_MARKETPLACE_URL).origin; + const origin = + source === "community" && window.location.origin !== communityHost + ? new URL(trans("home.marketplaceURL")).origin + : ""; + // This is a stored image read at the app's origin. No render requests or polling. + const url = `${origin}/api/v1/applications/${encodeURIComponent(appId)}/marketplace-preview/image`; + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); + useEffect(() => { + setLoaded(false); + setFailed(false); + }, [url]); + return ( + + {!failed && ( + {trans("marketplace.previewAlt", setLoaded(true)} + onError={() => setFailed(true)} + /> + )} + {!loaded && ( +
+ {failed ? : } + + {trans( + failed + ? "marketplace.previewUnavailable" + : "marketplace.loadingPreview", + )} + +
+ )} + + ); +} diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceStyles.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceStyles.tsx new file mode 100644 index 0000000000..114e304c2b --- /dev/null +++ b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceStyles.tsx @@ -0,0 +1,391 @@ +import styled from "styled-components"; + +export const MarketplaceShell = styled.div` + max-width: 1520px; + padding: 32px 36px 48px; + margin: 0 auto; + width: 100%; + min-width: 0; + .marketplace-heading { + display: flex; + align-items: start; + justify-content: space-between; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 28px; + } + .eyebrow { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--admin-accent); + margin-bottom: 10px; + } + h1 { + margin: 0; + font-size: clamp(28px, 3vw, 40px); + font-weight: 500; + letter-spacing: -0.04em; + line-height: 1.15; + overflow-wrap: anywhere; + } + .intro { + color: var(--admin-muted); + margin: 12px 0 0; + font-size: 15px; + line-height: 1.6; + max-width: 660px; + } + .tabs { + display: flex; + gap: 8px; + border-bottom: 1px solid var(--admin-line); + margin-bottom: 24px; + } + .tabs button { + border: 0; + border-bottom: 3px solid transparent; + background: none; + padding: 12px 16px 15px; + font: inherit; + font-weight: 500; + color: var(--admin-muted); + cursor: pointer; + } + .tabs button[aria-selected="true"] { + color: var(--admin-accent); + border-bottom-color: var(--admin-accent); + } + .tab-count { + font-size: 11px; + padding: 3px 7px; + margin-left: 7px; + background: #eee6f3; + border-radius: 8px; + } + .toolbar { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; + } + .search { + flex: 1 1 280px; + max-width: 540px; + } + .filter { + min-width: 140px; + } + .categories { + display: flex; + gap: 8px; + margin: 16px 0 22px; + flex-wrap: wrap; + } + .categories button { + font: inherit; + font-size: 12px; + border: 1px solid var(--admin-line); + border-radius: 999px; + padding: 7px 12px; + background: #fff; + cursor: pointer; + color: var(--admin-muted); + } + .categories button[aria-pressed="true"] { + background: var(--admin-accent); + color: white; + border-color: var(--admin-accent); + } + .results-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin: 20px 0 16px; + color: var(--admin-muted); + font-size: 13px; + } + .results-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + } + .view-switch { + display: flex; + gap: 4px; + } + .empty { + padding: 64px 20px; + border: 1px dashed var(--admin-line); + border-radius: 18px; + background: #ffffff80; + text-align: center; + } + .empty p { + color: var(--admin-muted); + max-width: 450px; + margin: 16px auto; + line-height: 1.6; + } + .back-link { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 28px; + font-size: 13px; + } + .detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 290px; + gap: 28px; + align-items: start; + } + .detail-preview { + border: 1px solid var(--admin-line); + border-radius: 16px; + overflow: hidden; + background: white; + } + .preview-caption { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + padding: 12px 18px; + font-size: 12px; + color: var(--admin-muted); + } + .detail-body { + margin-top: 28px; + padding: 28px; + background: white; + border: 1px solid var(--admin-line); + border-radius: 16px; + } + .detail-body h2, + .creator-panel h2 { + font-size: 18px; + font-weight: 500; + margin: 0 0 16px; + } + .description { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.8; + color: #66556e; + } + .creator-panel { + background: white; + border: 1px solid var(--admin-line); + border-radius: 16px; + padding: 24px; + } + .creator-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + .creator-name { + min-width: 0; + overflow-wrap: anywhere; + font-size: 13px; + color: var(--admin-muted); + } + .avatar { + display: inline-flex; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + align-items: center; + justify-content: center; + background: #efe4f7; + color: var(--admin-accent); + font-size: 11px; + font-weight: 500; + } + .creator-panel .avatar { + width: 42px; + height: 42px; + font-size: 16px; + } + .creator-panel dl { + border-top: 1px solid var(--admin-line); + margin: 24px 0; + padding-top: 8px; + } + .creator-panel dt { + color: var(--admin-muted); + font-size: 12px; + margin-top: 16px; + } + .creator-panel dd { + margin: 5px 0 0; + font-size: 13px; + overflow-wrap: anywhere; + } + .creator-panel .ant-btn { + white-space: normal; + height: auto; + min-height: 36px; + } + .tag { + background: var(--admin-tint); + color: #79558b; + border-radius: 6px; + padding: 4px 7px; + font-size: 10px; + line-height: 1.3; + } + .tags { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + } + .detail-tags { + margin-top: 16px; + } + .detail-tags .tag { + font-size: 12px; + padding: 6px 10px; + } + @media (min-width: 1500px) { + padding: 40px 48px; + } + @media (max-width: 1100px) { + padding: 24px; + .detail-grid { + grid-template-columns: minmax(0, 1fr) 240px; + gap: 20px; + } + } + @media (max-width: 850px) { + .detail-grid { + grid-template-columns: minmax(0, 1fr); + } + } + @media (max-width: 720px) { + padding: 24px 16px; + .tabs button { + padding: 12px 8px; + font-size: 13px; + } + .toolbar > .filter { + flex: 1; + min-width: 120px; + } + .search { + max-width: none; + } + .detail-body { + padding: 20px; + } + } +`; + +export const MarketplaceGrid = styled.div<{ $list: boolean }>` + display: grid; + grid-template-columns: ${({ $list }) => + $list + ? "minmax(0, 1fr)" + : "repeat(auto-fill, minmax(min(100%, 265px), 1fr))"}; + gap: 22px; + .marketplace-card { + display: flex; + flex-direction: ${({ $list }) => ($list ? "row" : "column")}; + min-width: 0; + border: 1px solid var(--admin-line); + border-radius: 16px; + overflow: hidden; + background: #fff; + transition: + box-shadow 0.2s, + border-color 0.2s; + } + .marketplace-card:hover { + border-color: #cdb5dc; + box-shadow: 0 10px 28px #4a255e10; + } + .cover-link { + display: block; + flex-shrink: 0; + width: ${({ $list }) => ($list ? "240px" : "100%")}; + } + .card-body { + padding: 20px; + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + } + .title-link { + margin: 12px 0 8px; + font-size: 17px; + font-weight: 500; + color: var(--admin-ink); + line-height: 1.35; + overflow-wrap: anywhere; + } + .title-link:hover { + color: var(--admin-accent); + } + .card-description { + color: var(--admin-muted); + line-height: 1.6; + font-size: 13px; + margin: 0 0 20px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 42px; + } + .card-footer { + margin-top: auto; + border-top: 1px solid #f1ebf4; + padding-top: 14px; + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + } + .creator-button { + border: 0; + background: none; + padding: 0; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font: inherit; + cursor: pointer; + text-align: left; + } + .creator-button .creator-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + } + .open-link { + display: flex; + gap: 6px; + align-items: center; + font-size: 12px; + flex-shrink: 0; + } + @media (max-width: 720px) { + .marketplace-card { + flex-direction: column; + } + .cover-link { + width: 100%; + } + } +`; diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.test.ts b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.test.ts new file mode 100644 index 0000000000..c77718b2aa --- /dev/null +++ b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.test.ts @@ -0,0 +1,88 @@ +import { AppTypeEnum } from "constants/applicationConstants"; +import { + appCategory, + filterMarketplaceApps, + MarketplaceApp, + marketplaceAppUrl, + marketplaceCategories, +} from "./marketplaceUtils"; +const apps: MarketplaceApp[] = [ + { + applicationId: "1", + applicationType: AppTypeEnum.Application, + name: "Internal name", + title: "Room Planner", + description: "Plan meeting capacity", + category: "BUSINESS", + creatorEmail: "Jane Smith", + createBy: "jane", + createAt: 1, + }, + { + applicationId: "2", + applicationType: AppTypeEnum.Module, + name: "Order module", + category: "Business", + creatorEmail: "Alex", + createBy: "alex", + createAt: 3, + }, + { + applicationId: "3", + applicationType: AppTypeEnum.Application, + name: "Weather", + createBy: "jane", + createAt: 2, + }, +]; +const filters = { + query: "", + category: "", + type: "", + creator: "", + sort: "name", +}; +test("searches published title, description and displayed creator together", () => { + expect( + filterMarketplaceApps(apps, { ...filters, query: "meeting Jane" }).map( + (a) => a.applicationId, + ), + ).toEqual(["1"]); + expect( + filterMarketplaceApps(apps, { ...filters, query: "room" }), + ).toHaveLength(1); + expect( + filterMarketplaceApps(apps, { ...filters, query: " " }), + ).toHaveLength(3); +}); +test("normalizes legacy category codes without splitting category counts", () => { + expect(appCategory(apps[0])).toBe("Business"); + expect(marketplaceCategories(apps)).toEqual([ + { value: "Business", count: 2 }, + ]); + expect( + filterMarketplaceApps(apps, { + ...filters, + category: "Business", + type: "2", + }).map((a) => a.applicationId), + ).toEqual(["2"]); +}); +test("creator filter and sorting preserve all entries and do not mutate the feed", () => { + expect( + filterMarketplaceApps(apps, { + ...filters, + creator: "jane", + sort: "newest", + }).map((a) => a.applicationId), + ).toEqual(["3", "1"]); + expect(apps.map((a) => a.applicationId)).toEqual(["1", "2", "3"]); +}); +test("local apps keep their local viewer and community apps use the frontend origin", () => { + expect(marketplaceAppUrl("1", "local", "https://app.lowcoder.cloud")).toBe( + "/apps/1/view_marketplace", + ); + expect( + marketplaceAppUrl("1", "community", "https://app.lowcoder.cloud"), + ).toBe("https://app.lowcoder.cloud/apps/1/view_marketplace"); +}); diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.ts b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.ts new file mode 100644 index 0000000000..e5dd9748c8 --- /dev/null +++ b/client/packages/lowcoder/src/pages/ApplicationV2/marketplace/marketplaceUtils.ts @@ -0,0 +1,102 @@ +import { + AppTypeEnum, + ApplicationCategoriesEnum, +} from "constants/applicationConstants"; + +export type MarketplaceSource = "community" | "local"; + +// Marketplace responses are deliberately smaller than an editable ApplicationMeta. +export interface MarketplaceApp { + applicationId: string; + applicationType: AppTypeEnum; + applicationStatus?: string; + name: string; + title?: string; + description?: string; + category?: string; + image?: string; // Legacy API name for the app icon, not a screenshot. + icon?: string; + creatorEmail?: string; // Legacy API name for the creator's display name. + createBy: string; + createAt: number; + orgName?: string; +} + +export const appTitle = (app: MarketplaceApp) => app.title?.trim() || app.name; +export const appCreator = (app: MarketplaceApp) => + app.creatorEmail?.trim() || ""; +export const appCategory = (app: MarketplaceApp) => { + const value = app.category?.trim() || ""; + return ( + ApplicationCategoriesEnum[ + value as keyof typeof ApplicationCategoriesEnum + ] || value + ); +}; + +export function filterMarketplaceApps( + apps: MarketplaceApp[], + filters: { + query: string; + category: string; + type: string; + creator: string; + sort: string; + }, +) { + const words = filters.query + .trim() + .toLocaleLowerCase() + .split(/\s+/) + .filter(Boolean); + return apps + .filter((app) => { + const text = [ + appTitle(app), + app.name, + app.description, + appCategory(app), + appCreator(app), + app.orgName, + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + return ( + words.every((word) => text.includes(word)) && + (!filters.category || appCategory(app) === filters.category) && + (!filters.type || String(app.applicationType) === filters.type) && + (!filters.creator || app.createBy === filters.creator) + ); + }) + .sort( + (a, b) => + (filters.sort === "newest" + ? (b.createAt || 0) - (a.createAt || 0) + : 0) || + appTitle(a).localeCompare(appTitle(b)) || + a.applicationId.localeCompare(b.applicationId), + ); +} + +export function marketplaceCategories(apps: MarketplaceApp[]) { + const counts = new Map(); + apps.forEach((app) => { + const category = appCategory(app); + if (category) counts.set(category, (counts.get(category) || 0) + 1); + }); + return Array.from(counts, ([value, count]) => ({ value, count })).sort( + (a, b) => a.value.localeCompare(b.value), + ); +} + +export function marketplaceAppUrl( + appId: string, + source: MarketplaceSource, + communityOrigin: string, +) { + const path = `/apps/${encodeURIComponent(appId)}/view_marketplace`; + return source === "local" + ? path + : `${communityOrigin.replace(/\/$/, "")}${path}`; +} diff --git a/client/packages/lowcoder/src/pages/common/header.tsx b/client/packages/lowcoder/src/pages/common/header.tsx index 146f14a1cb..0533e179e5 100644 --- a/client/packages/lowcoder/src/pages/common/header.tsx +++ b/client/packages/lowcoder/src/pages/common/header.tsx @@ -688,19 +688,21 @@ export default function Header(props: HeaderProps) { } // header in manager page -export function AppHeader() { +export function AppHeader({ showBrandName = false }: { showBrandName?: boolean }) { const user = useSelector(getUser); const brandingSettings = useSelector(getBrandingSetting); const headerStart = ( - history.push(ALL_APPLICATIONS_URL)}> + { event.preventDefault(); history.push(ALL_APPLICATIONS_URL); }}> {/* {REACT_APP_LOWCODER_SHOW_BRAND === 'true' ? REACT_APP_LOWCODER_CUSTOM_LOGO !== "" ? logo : : } */} { brandingSettings?.config_set?.logo ? ( Boolean(brandingSettings?.orgId) ? : - ) : + ) : showBrandName + ? Lowcoder + : } ); diff --git a/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx b/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx index cc83e64e63..8ca2683da5 100644 --- a/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx +++ b/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx @@ -1,6 +1,9 @@ import styled from "styled-components"; import { EditPopover, PointIcon, Search, TacoButton } from "lowcoder-design"; -import { useState, useEffect } from "react"; +import { useState, useCallback } from "react"; +import { AdminPage, AdminPageHeader, AdminSurface } from "components/layout/adminTheme"; +import { useAdminPagination } from "components/layout/useAdminPagination"; +import { AdminListError } from "components/layout/AdminListFeedback"; import { useDebouncedValue } from "util/hooks"; import { useDispatch, useSelector } from "react-redux"; import { getDataSourceTypesMap } from "../../redux/selectors/datasourceSelectors"; @@ -18,32 +21,17 @@ import { DatasourcePermissionDialog } from "../../components/PermissionDialog/Da import DataSourceIcon from "components/DataSourceIcon"; import { Helmet } from "react-helmet"; import LoadingOutlined from "@ant-design/icons/LoadingOutlined"; -import PaginationComp from "@lowcoder-ee/util/pagination/Pagination"; +import { AdminPagination } from "components/layout/AdminPagination"; import {DatasourceInfo} from "@lowcoder-ee/api/datasourceApi"; import {fetchDatasourcePagination} from "@lowcoder-ee/util/pagination/axios"; import {getUser} from "@lowcoder-ee/redux/selectors/usersSelectors"; -const DatasourceWrapper = styled.div` - display: flex; - flex-direction: column; - width: 100%; - height: 100%; -`; +const DatasourceWrapper = AdminPage; -const HeaderWrapper = styled.div` - display: flex; - align-items: center; - height: 92px; - padding: 28px 36px; - width: 100%; -`; +const HeaderWrapper = AdminPageHeader; const Title = styled.div` - font-weight: 500; - font-size: 18px; - color: #222222; - line-height: 18px; - flex-grow: 1; + flex: 1 0 240px; `; const AddBtn = styled(TacoButton)` @@ -53,15 +41,14 @@ const AddBtn = styled(TacoButton)` `; const EditBtn = styled(TacoButton)` - opacity: 0; + opacity: 1; width: 52px; height: 24px; `; -const BodyWrapper = styled.div` - width: 100%; - flex-grow: 1; - padding: 0 24px; +const BodyWrapper = styled(AdminSurface)` + padding: 12px 16px; + @media (max-width: 720px) { padding: 8px; } `; const DatasourceName = styled.div` @@ -108,57 +95,20 @@ const StyledTable = styled(Table)` export const DatasourceList = () => { const dispatch = useDispatch(); const [searchValue, setSearchValue] = useState(""); - const [searchValues, setSearchValues] = useState(""); const [isCreateFormShow, showCreateForm] = useState(false); const [shareDatasourceId, setShareDatasourceId] = useState(undefined); const [modify, setModify] = useState(false); const currentUser = useSelector(getUser); const orgId = currentUser.currentOrgId; const plugins = useSelector(getDataSourceTypesMap); - interface ElementsState { - elements: DatasourceInfo[]; - total: number; - } - - const [elements, setElements] = useState({ elements: [], total: 0 }); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [paginationLoading, setPaginationLoading] = useState(false); - const debouncedSearchValue = useDebouncedValue(searchValue, 500); - - useEffect(() => { - if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { - setSearchValues(debouncedSearchValue); - } - }, [debouncedSearchValue]); - - useEffect( () => { - setPaginationLoading(true); - fetchDatasourcePagination( - { - orgId: orgId, - pageNum: currentPage, - pageSize: pageSize, - name: searchValues - } - ).then((result: any) => { - if (result.success){ - setElements({elements: result.data || [], total: result.total || 1}) - } - else - console.error("ERROR: fetchFolderElements", result.error) - }).finally(() => { - setPaginationLoading(false); - }) - }, [currentPage, pageSize, searchValues, modify] - ) - - useEffect( () => { - if (searchValues !== "") - setCurrentPage(1); - }, [searchValues] - ); + const debouncedSearch = useDebouncedValue(searchValue, 300).trim(); + const loadPage = useCallback((page: number, size: number) => fetchDatasourcePagination({ + orgId, pageNum: page, pageSize: size, name: debouncedSearch, + }), [orgId, debouncedSearch, modify]); + const pagination = useAdminPagination({ scope: orgId, filterKey: debouncedSearch, loadPage }); + const { currentPage, pageSize, setCurrentPage, setPageSize, loading: paginationLoading } = pagination; + const elements = pagination; return ( <> @@ -186,8 +136,9 @@ export const DatasourceList = () => { ]} /> - {trans("home.datasource")} + <h1>{trans("home.datasource")}</h1><p>{trans("admin.datasourceDescription")}</p> setSearchValue(e.target.value)} @@ -197,6 +148,7 @@ export const DatasourceList = () => { + {pagination.error && } { }} rowClassName={(record: any) => (!record.edit ? "datasource-can-not-edit" : "")} tableLayout={"auto"} - scroll={{ x: "100%" }} + scroll={{ x: 780 }} pagination={false} onRow={(record: any) => ({ onClick: () => record.edit && history.push(buildDatasourceEditUrl(record.id)), @@ -213,6 +165,7 @@ export const DatasourceList = () => { { title: trans("home.datasourceName"), dataIndex: "name", + render: (name, record: any) => {name}, ellipsis: true, sorter: (a: any, b: any) => { if (a.name === b.name) { @@ -259,7 +212,7 @@ export const DatasourceList = () => { if (a.creator === b.creator) { return 0; } - return a.type > b.type ? 1 : -1; + return (a.creator ?? "").localeCompare(b.creator ?? ""); }, render: (text) => {text}, }, @@ -283,6 +236,7 @@ export const DatasourceList = () => { { title: " ", dataIndex: "operation", + fixed: "right", width: "152px", render: (_, record: any) => ( <> @@ -324,14 +278,10 @@ export const DatasourceList = () => { ]} dataSource={elements.elements .map((info, i) => ({ - key: i, + key: info.datasource.id, id: info.datasource.id, - name: ( - - - {info.datasource.name} - - ), + name: info.datasource.name, + datasourceType: info.datasource.type, type: plugins[info.datasource.type]?.name, database: (info.datasource.datasourceConfig as any)?.database ?? (info.datasource.datasourceConfig as any)?.serviceName, @@ -339,7 +289,7 @@ export const DatasourceList = () => { creator: info.creatorName, edit: info.edit, }))} /> - { !!elements.elements.length ? ` display: flex; flex-direction: column; - width: 264px; + width: 280px; + flex: 0 0 280px; + min-height: 0; height: 100%; - border-right: 1px solid #e1e3eb; + border-right: 1px solid var(--admin-line); + background: #fdfbfe; + @media (max-width: 1000px) { width: 100%; flex: 0 0 300px; height: 300px; border-right: 0; border-bottom: 1px solid var(--admin-line); } ${(props) => { if (props.$readOnly) { return css` @@ -42,7 +48,7 @@ const Wrapper = styled.div<{ $readOnly?: boolean }>` const Header = styled.div` display: flex; gap: 8px; - margin-top: 16px; + margin-top: 0; padding: 16px; height: 64px; `; @@ -74,7 +80,8 @@ const CreateBtn = styled(TacoButton)<{ $readOnly?: boolean }>` `; const Body = styled.div` - height: calc(100% - 120px); + flex: 1; + min-height: 0; display: flex; flex-direction: column; `; @@ -92,14 +99,18 @@ const SelectListWrapper = styled.div` height: calc(100% - 19px); `; const SelectItem = styled.div<{ $selected: boolean; $readOnly?: boolean }>` - height: 80px; + height: 88px; + margin: 4px 10px; + border-radius: 10px; + border: 1px solid transparent; + &:focus-visible { outline: 2px solid #b480de; outline-offset: -2px; } padding: 0 16px; background-color: ${(props) => - props.$selected ? (props.$readOnly ? "#EFEFF1" : "#F2F7FC") : "unset"}; + props.$selected ? (props.$readOnly ? "#EFEFF1" : "#f0e5f7") : "unset"}; cursor: pointer; &:hover { - background-color: ${(props) => !props.$selected && "#f2f7fc80"}; + background-color: ${(props) => !props.$selected && "#f7f0fb"}; } `; @@ -156,6 +167,9 @@ const PopoverIcon = styled(PointIcon)` export const LeftNav = (props: { queryList: LibraryQuery[]; + loading?: boolean; + error?: boolean; + onRetry?: () => void; selectedQuery?: string; addQuery: () => void; onSelect: (queryId: string) => void; @@ -179,7 +193,7 @@ export const LeftNav = (props: { useEffect(() => { if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { - setSearchValues(debouncedSearchValue); + setSearchValues(debouncedSearchValue.trim()); } }, [debouncedSearchValue]); @@ -200,11 +214,11 @@ export const LeftNav = (props: { {trans("newItem")} - + {trans("queryLibrary.allQuery")} - {props.queryList.length > 0 ? ( + {props.loading ? : props.error ? {})} /> : props.queryList.length > 0 ? ( props.queryList .filter((q) => { let datasourceTypeName = @@ -215,6 +229,12 @@ export const LeftNav = (props: { .map((q) => ( { + if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); props.onSelect(q.id); } + }} $selected={q.id === props.selectedQuery} $readOnly={props.readOnly} onClick={() => props.onSelect(q.id)} @@ -291,10 +311,7 @@ export const LeftNav = (props: { - { @@ -94,10 +100,7 @@ export const QueryLibraryEditor = () => { const [publishModalVisible, setPublishModalVisible] = useState(false); const [showHistory, setShowHistory] = useState(false); const [isDataSourceReady, setIsDataSourceReady] = useState(false); - const [elements, setElements] = useState({ elements: [], total: 0 }); const [queryLibrary, setQueryLibrary] = useState({}); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); const [searchValues, setSearchValues] = useState(""); const [modify, setModify] = useState(false); @@ -124,38 +127,33 @@ export const QueryLibraryEditor = () => { const [comp, container] = useCompInstance(params); useSaveQueryLibrary(libraryQuery, comp); + const loadPage = useCallback((page: number, size: number) => fetchQLPaginationByOrg({ + name: searchValues, pageNum: page, pageSize: size, + }), [searchValues, modify]); + const pagination = useAdminPagination({ scope: orgId, filterKey: searchValues, loadPage }); + const { currentPage, pageSize, setCurrentPage, setPageSize } = pagination; + const elements = pagination; + useEffect(() => { - try { - fetchQLPaginationByOrg( - { - name: searchValues, - pageNum: currentPage, - pageSize: pageSize, - } - ).then(result => { - if (result.success){ - setElements({elements: result.data || [], total: result.total || 1}) - setQueryLibrary(transformData(result.data || [])); - } - }); - } catch (error) { - console.error(error) - } - }, [currentPage, pageSize, searchValues, modify]) - - useEffect( () => { - if (searchValues !== "") - setCurrentPage(1); - }, [searchValues] - ); + setQueryLibrary({}); + setSelectedQuery(forwardQueryId ?? ""); + setShowHistory(false); + setIsDataSourceReady(false); + }, [orgId]); + + useEffect(() => { + if (!pagination.loading && !pagination.error) setQueryLibrary(transformData(pagination.elements)); + }, [pagination.elements, pagination.loading, pagination.error]); useEffect(() => { + let active = true; if (orgId) { dispatch(fetchDataSourceTypes({ organizationId: orgId })); dispatch( fetchDatasource({ organizationId: orgId, onSuccess: (dataSources) => { + if (!active) return; dataSources.forEach((di) => { const dataSource = di.datasource; if (dataSource.pluginDefinition) { @@ -171,14 +169,14 @@ export const QueryLibraryEditor = () => { }) ); } + return () => { active = false; }; }, [dispatch, orgId]); useEffect(() => { - if (!forwardQueryId && !queryLibrary[selectedQuery]) { - // @ts-ignore - setSelectedQuery(Object.values(queryLibrary)?.[0]?.id); + if (!forwardQueryId) { + setSelectedQuery(previous => queryLibrary[previous] ? previous : (Object.values(queryLibrary) as LibraryQuery[])[0]?.id ?? ""); } - }, [dispatch, Object.keys(queryLibrary).length]); + }, [queryLibrary, forwardQueryId]); useEffect(() => { Object.values(selectedRecords).length === 0 && @@ -246,7 +244,7 @@ export const QueryLibraryEditor = () => { setTimeout(() => { setModify(!modify); }, 200); - setCurrentPage(Math.ceil(elements.total / pageSize)); + setCurrentPage(Math.max(1, Math.ceil((elements.total + 1) / pageSize))); }, () => {} ) @@ -257,8 +255,14 @@ export const QueryLibraryEditor = () => { return ( {{trans("home.queryLibrary")}} + +

{trans("home.queryLibrary")}

{trans("admin.queriesDescription")}

+
showCreatePanel(true)} @@ -310,7 +314,7 @@ export const QueryLibraryEditor = () => { setTimeout(() => { setModify(!modify); }, 200); - setCurrentPage(Math.ceil(elements.total / pageSize)); + setCurrentPage(Math.max(1, Math.ceil((elements.total + 1) / pageSize))); }, })} /> )} diff --git a/client/packages/lowcoder/src/pages/setting/environments/index.tsx b/client/packages/lowcoder/src/pages/setting/environments/index.tsx index 967b8d70a1..b95b16c278 100644 --- a/client/packages/lowcoder/src/pages/setting/environments/index.tsx +++ b/client/packages/lowcoder/src/pages/setting/environments/index.tsx @@ -90,7 +90,7 @@ const EnvironmentsPromo = () => { -
+ { }} /> - + { }} /> - + + {trans("settings.environments")} {!isLicenseActive && } @@ -104,7 +104,7 @@ export function SettingHome() { { key: SettingPageEnum.AppUsage, label: ( - + {trans("settings.appUsage")} {!isLicenseActive && } @@ -115,7 +115,7 @@ export function SettingHome() { { key: SettingPageEnum.AuditLog, label: ( - + {trans("settings.audit")} {!isLicenseActive && } @@ -126,7 +126,7 @@ export function SettingHome() { { key: SettingPageEnum.Branding, label: ( - + {trans("settings.branding")} {!isLicenseActive && } @@ -162,7 +162,7 @@ export function SettingHome() { /> {isPublicCloud ? ( - +
{trans("enterprise.selfHosted.sidebarNotice")}
) : !isLicenseActive && currentOrgAdmin(user) && ( - +
{trans("enterprise.premiumFeaturesNotice")}
@@ -212,7 +212,7 @@ export function SettingHome() { {selectKey === SettingPageEnum.AppUsage && } {selectKey === SettingPageEnum.AuditLog && } {selectKey === SettingPageEnum.Branding && } - {selectKey === SettingPageEnum.Subscription &&
} + {selectKey === SettingPageEnum.Subscription && } {selectKey === SettingPageEnum.Enterprise && {trans("enterprise.selfHosted.title")} diff --git a/client/packages/lowcoder/src/pages/setting/styled.tsx b/client/packages/lowcoder/src/pages/setting/styled.tsx index 72cc4f5e73..bf3b023f2a 100644 --- a/client/packages/lowcoder/src/pages/setting/styled.tsx +++ b/client/packages/lowcoder/src/pages/setting/styled.tsx @@ -5,15 +5,28 @@ import styled from "styled-components"; export const TwoColumnSettingPageContent = styled.div` display: flex; height: 100%; + min-width: 0; + @media (max-width: 1100px) { flex-direction: column; height: auto; } `; export const Level1SettingPageContent = styled.div` padding: 32px 36px 32px 24px; max-width: 2200px; - min-width: 600px; + min-width: 0; width: 100%; box-sizing: border-box; overflow: auto; + overflow-wrap: anywhere; + .ant-card { min-width: 0; } + .ant-card-head-title { white-space: normal; overflow-wrap: anywhere; padding: 16px 0; } + .ant-card-head-wrapper { min-width: 0; gap: 12px; } + @media (max-width: 720px) { padding: 24px 16px; } +`; + +// All subscription routes and the license panel share one page gutter. +export const SubscriptionPageContent = styled(Level1SettingPageContent)` + > ${Level1SettingPageContent} { padding: 0; overflow: visible; } + > .ant-card { margin-top: 24px; } `; export const Level1SettingPageContentWithList = styled(Level1SettingPageContent)` @@ -21,19 +34,24 @@ export const Level1SettingPageContentWithList = styled(Level1SettingPageContent) `; export const Level1SettingPageTitle = styled.div` - font-size: 20px; + font-size: 26px; font-weight: 500; - color: #222; - line-height: 1; + color: var(--admin-ink, #281a30); + line-height: 1.3; + letter-spacing: -0.025em; display: flex; + min-width: 0; justify-items: center; - margin-bottom: 40px; + margin-bottom: 28px; `; export const Level1SettingPageTitleWithBtn = styled(Level1SettingPageTitle)` justify-content: space-between; margin: 0 12px 15px 12px; - height: 32px; + min-height: 38px; + height: auto; + flex-wrap: wrap; + gap: 12px; `; export const SettingContent = styled.div` max-width: 840px; diff --git a/client/packages/lowcoder/src/pages/setting/subscriptions/productCard.tsx b/client/packages/lowcoder/src/pages/setting/subscriptions/productCard.tsx index 45d181f80b..b6495f796a 100644 --- a/client/packages/lowcoder/src/pages/setting/subscriptions/productCard.tsx +++ b/client/packages/lowcoder/src/pages/setting/subscriptions/productCard.tsx @@ -9,8 +9,25 @@ import { SubscriptionProductsEnum } from "constants/subscriptionConstants"; import { trans } from "i18n"; const ProductCardContainer = styled(Card)` - width: 300px; - margin-bottom: 20px; + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + .ant-card-body { flex: 1; } + .ant-card-cover img, .product-placeholder { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + background: #f2f2f2; + } + .product-placeholder { display: grid; place-items: center; } + .ant-card-actions { + display: flex; + flex-wrap: wrap; + > li { flex: 1 1 125px; } + .ant-btn { height: auto; min-height: 32px; white-space: normal; } + .ant-btn > span { min-width: 0; overflow-wrap: anywhere; } + } `; const ProductTitle = styled.h3` @@ -86,7 +103,7 @@ export const ProductCard: React.FC = ({ hoverable loading={loading} cover={ - image ? {title} :
+ image ? {title} :
} actions={[ , diff --git a/client/packages/lowcoder/src/pages/setting/subscriptions/subscriptionSetting.tsx b/client/packages/lowcoder/src/pages/setting/subscriptions/subscriptionSetting.tsx index 6310d8d28f..195145b8cd 100644 --- a/client/packages/lowcoder/src/pages/setting/subscriptions/subscriptionSetting.tsx +++ b/client/packages/lowcoder/src/pages/setting/subscriptions/subscriptionSetting.tsx @@ -2,7 +2,7 @@ import styled from "styled-components"; import { GreyTextColor } from "constants/style"; import { trans } from "i18n"; import { Level1SettingPageContent, Level1SettingPageTitle } from "../styled"; -import { Flex, Card, Button, message, Alert } from 'antd'; +import { Card, Button, message, Alert } from 'antd'; import { useSeatSyncFailure } from "util/workspaceSeatSync"; import { useSelector } from "react-redux"; import { getUser } from "redux/selectors/usersSelectors"; @@ -31,11 +31,20 @@ const SubscriptionSettingContent = styled.div` const CardWrapper = styled(Card)` width: 100%; - margin-bottom: 24px; +`; + +const ProductGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 24px; + margin: 24px 0; `; const ManageSubscriptionButton = styled(Button)` - margin-top: 24px; + max-width: 100%; + height: auto; + min-height: 32px; + white-space: normal; `; export function SubscriptionSetting() { @@ -88,7 +97,7 @@ export function SubscriptionSetting() { {customer != null ? ( {customer &&

{trans("automator.subscription.customerNumber", { id: customer.id.substring(4) })} {admin === "admin" && <>| {trans("automator.subscription.adminNote")}}

} - + {products .filter((product) => { // Instance licenses require deployment IDs and their dedicated checkout. @@ -117,7 +126,7 @@ export function SubscriptionSetting() { /> ); } )} - + {/* Manage Subscription Button */} diff --git a/client/packages/lowcoder/vite.config.mts b/client/packages/lowcoder/vite.config.mts index ea671442a7..009d9d417c 100644 --- a/client/packages/lowcoder/vite.config.mts +++ b/client/packages/lowcoder/vite.config.mts @@ -81,6 +81,7 @@ export const viteConfig: UserConfig = { assetsDir: "static", emptyOutDir: false, rollupOptions: { + input: { main: path.resolve(__dirname, "index.html"), marketplacePreview: path.resolve(__dirname, "marketplace-preview.html") }, treeshake: { moduleSideEffects: true, propertyReadSideEffects: false, @@ -274,11 +275,10 @@ export const viteConfig: UserConfig = { globalDepPlugin(), createHtmlPlugin({ minify: true, - inject: { - data: { - browserCheckScript: isDev ? "" : ``, - }, - }, + pages: [ + { filename: "index.html", template: "index.html", injectOptions: { data: { browserCheckScript: isDev ? "" : `` } } }, + { filename: "marketplace-preview.html", template: "marketplace-preview.html" }, + ], }), isVisualizerEnabled && visualizer(), dynamicImport(), @@ -297,6 +297,7 @@ const browserCheckConfig: UserConfig = { manifest: false, copyPublicDir: true, emptyOutDir: true, + rollupOptions: { ...viteConfig.build?.rollupOptions, input: undefined }, lib: { formats: ["es"], name: "BrowserCheck", diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index f0ff6f6072..39cf75f885 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -102,6 +102,13 @@ RUN apt-get update \ COPY --from=build-node-service /lowcoder/node-service /lowcoder/node-service +# Automatic marketplace layout captures. Use Playwright's matching Chromium build. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/lowcoder-browsers +RUN cd /lowcoder/node-service/app && node node_modules/playwright/cli.js install --with-deps chromium \ + && chmod -R a+rX /opt/lowcoder-browsers \ + && rm -rf /var/lib/apt/lists/* + + EXPOSE 6060 CMD [ "/bin/sh", "/lowcoder/node-service/entrypoint.sh" ] @@ -410,6 +417,11 @@ RUN mkdir -p /lowcoder/plugins/ && chown lowcoder:lowcoder /lowcoder/plugins/ # Add lowcoder node-service COPY --chown=lowcoder:lowcoder --from=lowcoder-ce-node-service /lowcoder/node-service /lowcoder/node-service +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/lowcoder-browsers +COPY --from=lowcoder-ce-node-service /opt/lowcoder-browsers /opt/lowcoder-browsers +RUN cd /lowcoder/node-service/app && node node_modules/playwright/cli.js install-deps chromium \ + && rm -rf /var/lib/apt/lists/* + # Add lowcoder proxy-service COPY --chown=lowcoder:lowcoder --from=build-proxy-service /lowcoder/proxy-service /lowcoder/proxy-service diff --git a/deploy/docker/default-multi.env b/deploy/docker/default-multi.env index d5fbe75523..8afcebd4fc 100644 --- a/deploy/docker/default-multi.env +++ b/deploy/docker/default-multi.env @@ -22,3 +22,5 @@ LOWCODER_PROXY_SERVICE_URL="http://lowcoder-proxy-service:6070" LOWCODER_HOCUSPOCUS_SERVICE_URL="http://hocuspocus:3006" LOWCODER_AGORA_TOKEN_SERVICE_URL="http://agora-token-service:8080" + +LOWCODER_PREVIEW_FRONTEND_URL="http://lowcoder-frontend:3000" diff --git a/deploy/docker/default.env b/deploy/docker/default.env index 978219e48a..c62a59c58a 100644 --- a/deploy/docker/default.env +++ b/deploy/docker/default.env @@ -198,3 +198,6 @@ LOWCODER_SUPERUSER_USERNAME=admin@localhost LOWCODER_SUPERUSER_PASSWORD= + +# Frontend origin used by the isolated automatic marketplace preview renderer. +LOWCODER_PREVIEW_FRONTEND_URL="http://localhost:3000" diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/application/repository/ApplicationRecordRepository.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/application/repository/ApplicationRecordRepository.java index cb447582b4..8ad2c04b4c 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/application/repository/ApplicationRecordRepository.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/application/repository/ApplicationRecordRepository.java @@ -12,6 +12,8 @@ @Repository public interface ApplicationRecordRepository extends ReactiveMongoRepository { + Mono existsByApplicationId(String applicationId); + Mono deleteByApplicationId(String applicationId); Flux findByApplicationId(String applicationId); diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreview.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreview.java new file mode 100644 index 0000000000..d55c16da47 --- /dev/null +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreview.java @@ -0,0 +1,22 @@ +package org.lowcoder.api.application.marketplace; + +import java.time.Instant; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** One durable job and last successful image per locally owned application. */ +@Data +@Document("marketplacePreview") +public class MarketplacePreview { + @Id + private String id; + private String desiredHash; + private String renderedHash; + private byte[] image; + private String status; + private int attempts; + private Instant nextAttemptAt; + private Instant leaseUntil; + private String leaseToken; +} diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewController.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewController.java new file mode 100644 index 0000000000..bf03cb1c14 --- /dev/null +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewController.java @@ -0,0 +1,55 @@ +package org.lowcoder.api.application.marketplace; + +import lombok.RequiredArgsConstructor; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.domain.application.model.ApplicationRequestType; +import org.lowcoder.domain.application.repository.ApplicationRecordRepository; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.infra.constant.NewUrl; +import org.lowcoder.infra.constant.Url; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +public class MarketplacePreviewController { + private final ApplicationService applications; + private final ApplicationRecordRepository versions; + private final ResourcePermissionService permissions; + private final SessionUserService users; + private final MarketplacePreviewStore store; + + /** Read-only: never queues, captures, loads the full app DSL or records an app view. */ + @GetMapping({Url.APPLICATION_URL + "/{applicationId}/marketplace-preview/image", + NewUrl.APPLICATION_URL + "/{applicationId}/marketplace-preview/image"}) + public Mono> image(@PathVariable String applicationId, + @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String etag) { + return applications.findByIdWithoutDsl(applicationId) + .filter(MarketplacePreviewWorker::eligible) + .flatMap(app -> users.getVisitorId() + .flatMap(user -> permissions.checkUserPermissionStatusOnApplication(user, app.getId(), + ResourceAction.READ_APPLICATIONS, ApplicationRequestType.PUBLIC_TO_MARKETPLACE)) + .filter(status -> status.hasPermission()) + // Require a real publication even if all published versions were deleted. + .flatMap(ignored -> versions.existsByApplicationId(app.getId()).filter(Boolean::booleanValue)) + .flatMap(ignored -> store.read(app.getId()))) + .filter(preview -> preview.getImage() != null && preview.getRenderedHash() != null) + .map(preview -> { + String currentEtag = "\"" + preview.getRenderedHash() + "\""; + // Authorization happens before both 200 and 304. Unsharing revokes future reads. + if (currentEtag.equals(etag)) return ResponseEntity.status(304) + .header(HttpHeaders.CACHE_CONTROL, "private, no-cache").eTag(currentEtag).build(); + return ResponseEntity.ok().contentType(MediaType.IMAGE_JPEG) + .header(HttpHeaders.CACHE_CONTROL, "private, no-cache").eTag(currentEtag).body(preview.getImage()); + }) + .defaultIfEmpty(ResponseEntity.notFound().header(HttpHeaders.CACHE_CONTROL, "no-store").build()); + } +} diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewStore.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewStore.java new file mode 100644 index 0000000000..197560186c --- /dev/null +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewStore.java @@ -0,0 +1,86 @@ +package org.lowcoder.api.application.marketplace; + +import java.time.Instant; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class MarketplacePreviewStore { + private final ReactiveMongoTemplate mongo; + + public Mono ensureIndex() { + return mongo.indexOps(MarketplacePreview.class).ensureIndex(new Index() + .on("status", Sort.Direction.ASC).on("nextAttemptAt", Sort.Direction.ASC)).then(); + } + + public Mono read(String id) { + return mongo.findById(id, MarketplacePreview.class); + } + + public Mono desire(String id, String hash) { + Query metadata = Query.query(Criteria.where("_id").is(id)); + metadata.fields().exclude("image"); + return mongo.findOne(metadata, MarketplacePreview.class) + .flatMap(existing -> { + if (hash.equals(existing.getDesiredHash())) return Mono.just(true); + // Compare-and-set: a stale reconciliation cannot overwrite a concurrent update. + return mongo.updateFirst(Query.query(Criteria.where("_id").is(id) + .and("desiredHash").is(existing.getDesiredHash())), + pending(hash), MarketplacePreview.class).thenReturn(true); + }) + .switchIfEmpty(Mono.defer(() -> mongo.upsert(Query.query(Criteria.where("_id").is(id)), + new Update().setOnInsert("desiredHash", hash).setOnInsert("status", "pending") + .setOnInsert("attempts", 0).setOnInsert("nextAttemptAt", Instant.EPOCH) + .setOnInsert("leaseUntil", Instant.EPOCH), MarketplacePreview.class).thenReturn(true))) + .onErrorResume(DuplicateKeyException.class, ignored -> Mono.just(true)).then(); + } + + private Update pending(String hash) { + return new Update().set("desiredHash", hash).set("status", "pending").set("attempts", 0) + .set("nextAttemptAt", Instant.EPOCH).set("leaseUntil", Instant.EPOCH).unset("leaseToken"); + } + + public Mono claim(Instant now) { + Query query = Query.query(Criteria.where("status").is("pending").and("attempts").lt(3) + .and("nextAttemptAt").lte(now).and("leaseUntil").lte(now)); + query.with(Sort.by("nextAttemptAt")); + query.fields().exclude("image"); + return mongo.findAndModify(query, new Update().set("leaseToken", UUID.randomUUID().toString()) + .set("leaseUntil", now.plusSeconds(180)).inc("attempts", 1), + FindAndModifyOptions.options().returnNew(true), MarketplacePreview.class); + } + + private Query owned(MarketplacePreview job) { + return Query.query(Criteria.where("_id").is(job.getId()).and("desiredHash").is(job.getDesiredHash()) + .and("leaseToken").is(job.getLeaseToken())); + } + + public Mono complete(MarketplacePreview job, byte[] image) { + return mongo.updateFirst(owned(job), new Update().set("image", image) + .set("renderedHash", job.getDesiredHash()).set("status", "ready") + .unset("leaseToken").set("leaseUntil", Instant.EPOCH), MarketplacePreview.class).then(); + } + + public Mono fail(MarketplacePreview job, boolean busy) { + Update update = new Update().set("status", !busy && job.getAttempts() >= 3 ? "failed" : "pending") + .set("nextAttemptAt", Instant.now().plusSeconds(busy ? 10 : 60L * job.getAttempts() * job.getAttempts())) + .unset("leaseToken").set("leaseUntil", Instant.EPOCH); + if (busy) update.inc("attempts", -1); + return mongo.updateFirst(owned(job), update, MarketplacePreview.class).then(); + } + + public Mono discard(MarketplacePreview job) { + return mongo.remove(owned(job), MarketplacePreview.class).then(); + } +} diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewWorker.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewWorker.java new file mode 100644 index 0000000000..3bf8e65d8d --- /dev/null +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplacePreviewWorker.java @@ -0,0 +1,141 @@ +package org.lowcoder.api.application.marketplace; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.organization.model.Organization.OrganizationCommonSettings; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** Generates previews independently of visitor traffic, on the installation that owns the app. */ +@Component +@RequiredArgsConstructor +@Slf4j +public class MarketplacePreviewWorker { + // Bump when the renderer/components change. No time-based expiration of successful images. + static final String RENDER_VERSION = "layout-v3-1280x800"; + private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private final ApplicationService applications; + private final ApplicationRecordService versions; + private final OrganizationService organizations; + private final MarketplacePreviewStore store; + private final MarketplaceRenderClient renderer; + private final AtomicBoolean reconciling = new AtomicBoolean(); + private final AtomicBoolean working = new AtomicBoolean(); + + @Scheduled(initialDelay = 10000, fixedDelayString = "${LOWCODER_PREVIEW_RECONCILE_MS:60000}") + public void reconcile() { + if (!renderer.isConfigured() || !reconciling.compareAndSet(false, true)) return; + store.ensureIndex().thenMany(applications.findAllMarketplaceApps()) + .filter(MarketplacePreviewWorker::eligible) + .concatMap(app -> snapshot(app).flatMap(snapshot -> store.desire(app.getId(), snapshot.hash())) + .onErrorResume(error -> { + log.warn("Marketplace preview reconciliation failed for {}", app.getId(), error); + return Mono.empty(); + })) + .doFinally(ignored -> reconciling.set(false)) + .subscribe(ignored -> {}, error -> log.warn("Marketplace preview reconciliation failed", error)); + } + + @Scheduled(initialDelay = 15000, fixedDelay = 2000) + public void work() { + if (!renderer.isConfigured() || !working.compareAndSet(false, true)) return; + store.claim(Instant.now()).flatMap(this::capture) + .doFinally(ignored -> working.set(false)) + .subscribe(ignored -> {}, error -> log.warn("Marketplace preview worker failed", error)); + } + + Mono capture(MarketplacePreview job) { + return snapshot(job.getId()).flatMap(snapshot -> { + if (!snapshot.hash().equals(job.getDesiredHash())) + return store.desire(job.getId(), snapshot.hash()).thenReturn(true); + return renderer.render(snapshot.json()).flatMap(image -> snapshot(job.getId()) + .flatMap(current -> (current.hash().equals(job.getDesiredHash()) + ? store.complete(job, image) : store.desire(job.getId(), current.hash())).thenReturn(true)) + .switchIfEmpty(store.discard(job).thenReturn(false))); + }).switchIfEmpty(store.discard(job).thenReturn(false)).then() + .onErrorResume(error -> { + boolean busy = error instanceof WebClientResponseException response && response.getStatusCode().value() == 429; + log.warn("Marketplace preview {} attempt {} {}", job.getId(), job.getAttempts(), busy ? "deferred" : "failed"); + return store.fail(job, busy); + }); + } + + static boolean eligible(Application app) { + return app.getApplicationStatus() == ApplicationStatus.NORMAL && app.isPublicToAll() && app.isPublicToMarketplace(); + } + + Mono snapshot(String id) { + return applications.findByIdWithoutDsl(id).flatMap(this::snapshot); + } + + Mono snapshot(Application app) { + if (!eligible(app)) return Mono.empty(); + // Never use getLiveApplicationDsl: its fallback would expose an unpublished draft. + return versions.getLatestRecordByApplicationId(app.getId()).flatMap(version -> { + var dsl = version.getApplicationDSL(); + var modules = applications.getAllDependentModulesFromDsl(dsl).flatMapMany(Flux::fromIterable) + .filter(module -> module.getApplicationStatus() == ApplicationStatus.NORMAL) + .concatMap(module -> versions.getLatestRecordByApplicationId(module.getId()) + .map(published -> Map.entry(module.getId(), staticDsl(published.getApplicationDSL())))) + .collectMap(Map.Entry::getKey, Map.Entry::getValue); + return Mono.zip(modules, organizations.getOrgCommonSettings(app.getOrganizationId()).defaultIfEmpty(new OrganizationCommonSettings())) + .map(tuple -> { + Map themes = new TreeMap<>(); + themes.put("themeList", tuple.getT2().getOrDefault("themeList", List.of())); + if (tuple.getT2().get("defaultTheme") != null) themes.put("defaultTheme", tuple.getT2().get("defaultTheme")); + return snapshotOf(Map.of( + "applicationInfoView", Map.of("applicationId", app.getId(), "name", app.getName(), "applicationType", app.getApplicationType()), + "applicationDSL", staticDsl(dsl), "moduleDSL", tuple.getT1(), "orgCommonSettings", themes)); + }); + }); + } + + record Snapshot(String hash, String json) {} + + static Snapshot snapshotOf(Map payload) { + try { + String json = JSON.writeValueAsString(payload); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest((RENDER_VERSION + "\n" + json).getBytes(StandardCharsets.UTF_8))); + return new Snapshot(hash, json); + } catch (Exception error) { + throw new IllegalArgumentException("Cannot serialize marketplace layout", error); + } + } + + static Object staticDsl(Object value) { + if (value instanceof List list) return list.stream().map(MarketplacePreviewWorker::staticDsl).toList(); + if (!(value instanceof Map map)) return value; + Map result = new TreeMap<>(); + map.forEach((key, item) -> { + String name = key.toString(); + if (List.of("queries", "transformers", "dataResponders", "hooks", "events").contains(name)) result.put(name, List.of()); + else if (name.equals("preload")) { + Map css = new TreeMap<>(); + if (item instanceof Map preload) for (String field : List.of("css", "globalCSS")) { + if (preload.get(field) instanceof String style) css.put(field, style); + } + result.put(name, css); + } else result.put(name, staticDsl(item)); + }); + return result; + } +} diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplaceRenderClient.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplaceRenderClient.java new file mode 100644 index 0000000000..abc2891efc --- /dev/null +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/marketplace/MarketplaceRenderClient.java @@ -0,0 +1,47 @@ +package org.lowcoder.api.application.marketplace; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import org.lowcoder.infra.js.NodeServerHelper; +import org.lowcoder.sdk.config.CommonConfig; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class MarketplaceRenderClient { + private final CommonConfig config; + private final NodeServerHelper nodeServer; + private final WebClient client = WebClient.builder().codecs(codecs -> + codecs.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)).build(); + + public boolean isConfigured() { + return config.getJsExecutor().getPassword() != null && !config.getJsExecutor().getPassword().isBlank(); + } + + public Mono render(String payload) { + return Mono.defer(() -> { + if (!isConfigured()) return Mono.error(new IllegalStateException("Renderer secret is not configured")); + long timestamp = System.currentTimeMillis(); + String signature; + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(config.getJsExecutor().getPassword().getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + signature = HexFormat.of().formatHex(mac.doFinal((timestamp + "\n" + payload).getBytes(StandardCharsets.UTF_8))); + } catch (Exception error) { + return Mono.error(error); + } + return client.post().uri(nodeServer.createUri("marketplace/render")) + .bodyValue(Map.of("timestamp", timestamp, "payload", payload, "signature", signature)) + .retrieve().bodyToMono(byte[].class).timeout(Duration.ofSeconds(90)) + .filter(bytes -> bytes.length > 2 && bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xd8) + .switchIfEmpty(Mono.error(new IllegalStateException("Renderer did not return a JPEG"))); + }); + } +} 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 3c30597108..ec6788d947 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 @@ -100,6 +100,7 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*"), // application view ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/view"), // application view ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/view_marketplace"), // application view + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/*/marketplace-preview/image"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, APPLICATION_URL + "/marketplace-apps"), // marketplace apps ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, USER_URL + "/me"), @@ -135,6 +136,8 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/view"), ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/view_marketplace"), + // Image delivery performs the same marketplace access checks in its controller. + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/*/marketplace-preview/image"), 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"), @@ -190,6 +193,7 @@ private CorsConfigurationSource buildCorsConfigurationSource() { source.registerCorsConfiguration(APPLICATION_URL + "/*", skipCheckCorsForAll); source.registerCorsConfiguration(APPLICATION_URL + "/*/view", skipCheckCorsForAll); source.registerCorsConfiguration(APPLICATION_URL + "/*/view_marketplace", skipCheckCorsForAll); + source.registerCorsConfiguration(APPLICATION_URL + "/*/marketplace-preview/image", skipCheckCorsForAll); source.registerCorsConfiguration(APPLICATION_URL + "/marketplace-apps", skipCheckCorsForAll); source.registerCorsConfiguration(GITHUB_STAR, skipCheckCorsForAll); source.registerCorsConfiguration(ORGANIZATION_URL + "/*/datasourceTypes", skipCheckCorsForAll); @@ -202,6 +206,7 @@ private CorsConfigurationSource buildCorsConfigurationSource() { source.registerCorsConfiguration(NewUrl.APPLICATION_URL + "/*", skipCheckCorsForAll); source.registerCorsConfiguration(NewUrl.APPLICATION_URL + "/*/view", skipCheckCorsForAll); source.registerCorsConfiguration(NewUrl.APPLICATION_URL + "/*/view_marketplace", skipCheckCorsForAll); + source.registerCorsConfiguration(NewUrl.APPLICATION_URL + "/*/marketplace-preview/image", skipCheckCorsForAll); source.registerCorsConfiguration(NewUrl.APPLICATION_URL + "/marketplace-apps", skipCheckCorsForAll); source.registerCorsConfiguration(NewUrl.ORGANIZATION_URL + "/*/datasourceTypes", skipCheckCorsForAll); source.registerCorsConfiguration(NewUrl.DATASOURCE_URL + "/jsDatasourcePlugins", skipCheckCorsForAll); diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewMongoTest.java new file mode 100644 index 0000000000..5394ed846a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewMongoTest.java @@ -0,0 +1,113 @@ +package org.lowcoder.api.application.marketplace; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoClients; +import java.time.Instant; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.lowcoder.infra.js.NodeServerHelper; +import org.lowcoder.sdk.config.CommonConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import reactor.core.publisher.Flux; + +/** Uses an isolated disposable database; run with LOWCODER_PREVIEW_TEST_MONGO_URI. */ +@EnabledIfEnvironmentVariable(named = "LOWCODER_PREVIEW_TEST_MONGO_URI", matches = ".+") +class MarketplacePreviewMongoTest { + private MongoClient client; + private ReactiveMongoTemplate mongo; + private MarketplacePreviewStore store; + + @BeforeEach void setup() { + client = MongoClients.create(System.getenv("LOWCODER_PREVIEW_TEST_MONGO_URI")); + mongo = new ReactiveMongoTemplate(client, "preview_test_" + UUID.randomUUID().toString().replace("-", "")); + store = new MarketplacePreviewStore(mongo); + store.ensureIndex().block(); + } + @AfterEach void cleanup() { + mongo.getMongoDatabase().flatMap(db -> reactor.core.publisher.Mono.from(db.drop())).block(); + client.close(); + } + @Test void competingWorkersClaimExactlyOnceAndRestartsReusePersistedImage() { + Flux.range(0, 20).flatMap(i -> store.desire("app", "v1")).then().block(); + var claims = Flux.range(0, 20).flatMap(i -> new MarketplacePreviewStore(mongo).claim(Instant.now())).collectList().block(); + assertEquals(1, claims.size()); + store.complete(claims.get(0), new byte[]{1, 2, 3}).block(); + var restarted = new MarketplacePreviewStore(mongo); + restarted.desire("app", "v1").block(); + assertArrayEquals(new byte[]{1, 2, 3}, restarted.read("app").block().getImage()); + assertNull(restarted.claim(Instant.now().plusSeconds(86400)).block()); + } + @Test void changedVersionRetainsLastImageAndFencesOldWorkers() { + store.desire("app", "v1").block(); + var old = store.claim(Instant.now()).block(); + store.complete(old, new byte[]{1}).block(); + store.desire("app", "v2").block(); + var current = store.claim(Instant.now()).block(); + assertArrayEquals(new byte[]{1}, store.read("app").block().getImage()); + store.complete(old, new byte[]{9}).block(); + store.fail(old, false).block(); + store.complete(current, new byte[]{2}).block(); + assertArrayEquals(new byte[]{2}, store.read("app").block().getImage()); + assertEquals("v2", store.read("app").block().getRenderedHash()); + } + @Test void expiredLeaseCanBeRecoveredButOldOwnerCannotComplete() { + store.desire("app", "v1").block(); + var abandoned = store.claim(Instant.now()).block(); + mongo.updateFirst(Query.query(Criteria.where("_id").is("app")), + new Update().set("leaseUntil", Instant.EPOCH), MarketplacePreview.class).block(); + var recovered = store.claim(Instant.now()).block(); + assertNotEquals(abandoned.getLeaseToken(), recovered.getLeaseToken()); + store.complete(abandoned, new byte[]{9}).block(); + assertNull(store.read("app").block().getImage()); + store.complete(recovered, new byte[]{2}).block(); + assertArrayEquals(new byte[]{2}, store.read("app").block().getImage()); + } + @Test void failuresHaveDurableBoundedRetriesButNewLayoutGetsANewAttemptBudget() { + store.desire("app", "v1").block(); + for (int attempt = 1; attempt <= 3; attempt++) { + var job = store.claim(Instant.now().plusSeconds(attempt * 1000L)).block(); + assertNotNull(job); assertEquals(attempt, job.getAttempts()); + store.fail(job, false).block(); + } + assertNull(store.claim(Instant.now().plusSeconds(86400)).block()); + store.desire("app", "v1").block(); + assertEquals("failed", store.read("app").block().getStatus()); + store.desire("app", "v2").block(); + var job = store.claim(Instant.now()).block(); + assertEquals(1, job.getAttempts()); + store.fail(job, true).block(); + assertEquals(0, store.read("app").block().getAttempts()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "LOWCODER_PREVIEW_TEST_RENDERER_URL", matches = ".+") + void realInternalRendererImagePersistsAndCanBeReadAfterWorkerRestart() throws Exception { + var config = mock(CommonConfig.class, RETURNS_DEEP_STUBS); + when(config.getJsExecutor().getPassword()).thenReturn(Files.readString(Path.of(System.getenv("LOWCODER_PREVIEW_TEST_SECRET_FILE")))); + var node = mock(NodeServerHelper.class); + when(node.createUri("marketplace/render")).thenReturn(URI.create(System.getenv("LOWCODER_PREVIEW_TEST_RENDERER_URL") + "/node-service/api/marketplace/render")); + var renderer = new MarketplaceRenderClient(config, node); + var payload = Files.readString(Path.of(System.getenv("LOWCODER_PREVIEW_TEST_PAYLOAD_FILE"))); + store.desire("sample", "sample-layout").block(); + var job = store.claim(Instant.now()).block(); + var image = renderer.render(payload).block(); + assertNotNull(image); + assertTrue(image.length > 1000); + store.complete(job, image).block(); + var restarted = new MarketplacePreviewStore(new ReactiveMongoTemplate(client, mongo.getMongoDatabase().block().getName())); + restarted.desire("sample", "sample-layout").block(); + assertArrayEquals(image, restarted.read("sample").block().getImage()); + assertNull(restarted.claim(Instant.now().plusSeconds(86400)).block()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewTest.java new file mode 100644 index 0000000000..cd92be239f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/marketplace/MarketplacePreviewTest.java @@ -0,0 +1,120 @@ +package org.lowcoder.api.application.marketplace; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.application.model.ApplicationVersion; +import org.lowcoder.domain.application.repository.ApplicationRecordRepository; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.organization.model.Organization.OrganizationCommonSettings; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.UserPermissionOnResourceStatus; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +class MarketplacePreviewTest { + private final ApplicationService apps = mock(ApplicationService.class); + private final ApplicationRecordService versions = mock(ApplicationRecordService.class); + private final OrganizationService orgs = mock(OrganizationService.class); + private final MarketplacePreviewStore store = mock(MarketplacePreviewStore.class); + private final MarketplaceRenderClient renderer = mock(MarketplaceRenderClient.class); + private final MarketplacePreviewWorker worker = new MarketplacePreviewWorker(apps, versions, orgs, store, renderer); + + private Application app(ApplicationStatus status, boolean shared) { + return Application.builder().id("app").name("Example").organizationId("org") + .applicationStatus(status).publicToAll(shared).publicToMarketplace(shared).build(); + } + + @Test + void canonicalHashIgnoresMapOrderButChangesWithLayoutThemeAndModules() { + Map first = new LinkedHashMap<>(); + first.put("ui", Map.of("title", "App")); first.put("theme", "light"); + Map reordered = new LinkedHashMap<>(); + reordered.put("theme", "light"); reordered.put("ui", Map.of("title", "App")); + assertEquals(MarketplacePreviewWorker.snapshotOf(first).hash(), MarketplacePreviewWorker.snapshotOf(reordered).hash()); + for (Map change : List.of(Map.of("ui", "new"), Map.of("theme", "dark"), + Map.of("moduleDSL", Map.of("child", "changed")))) + assertNotEquals(MarketplacePreviewWorker.snapshotOf(first).hash(), MarketplacePreviewWorker.snapshotOf(change).hash()); + } + + @Test + void sanitizedCaptureKeepsStylesAndRemovesQueriesEventsAndPreloadScripts() { + Map input = Map.of("ui", Map.of("title", "Keep", "events", List.of("execute")), + "queries", List.of(Map.of("password", "secret")), "preload", Map.of("js", "execute()", "css", ".x{color:red}")); + String json = MarketplacePreviewWorker.snapshotOf(Map.of("applicationDSL", MarketplacePreviewWorker.staticDsl(input))).json(); + assertFalse(json.contains("secret")); assertFalse(json.contains("execute")); + assertTrue(json.contains("Keep")); assertTrue(json.contains(".x{color:red}")); + } + + @Test + void unpublishedDraftAndUnsharedOrRecycledAppsNeverEnterCapture() { + when(versions.getLatestRecordByApplicationId("app")).thenReturn(Mono.empty()); + assertNull(worker.snapshot(app(ApplicationStatus.NORMAL, true)).block()); + assertNull(worker.snapshot(app(ApplicationStatus.NORMAL, false)).block()); + assertNull(worker.snapshot(app(ApplicationStatus.RECYCLED, true)).block()); + verifyNoInteractions(orgs, renderer, store); + } + + @Test + void changedPublicationDuringCaptureCannotStoreTheOldImage() { + var app = app(ApplicationStatus.NORMAL, true); + when(apps.findByIdWithoutDsl("app")).thenReturn(Mono.just(app)); + var oldVersion = ApplicationVersion.builder().applicationDSL(Map.of("ui", "old")).build(); + var newVersion = ApplicationVersion.builder().applicationDSL(Map.of("ui", "new")).build(); + when(versions.getLatestRecordByApplicationId("app")).thenReturn(Mono.just(oldVersion)); + when(apps.getAllDependentModulesFromDsl(anyMap())).thenReturn(Mono.just(List.of())); + when(orgs.getOrgCommonSettings("org")).thenReturn(Mono.just(new OrganizationCommonSettings())); + var before = worker.snapshot("app").block(); + var job = new MarketplacePreview(); job.setId("app"); job.setDesiredHash(before.hash()); + when(renderer.render(anyString())).thenAnswer(ignored -> { + when(versions.getLatestRecordByApplicationId("app")).thenReturn(Mono.just(newVersion)); + return Mono.just(new byte[]{1, 2}); + }); + when(store.desire(eq("app"), anyString())).thenReturn(Mono.empty()); + when(store.discard(job)).thenReturn(Mono.empty()); + worker.capture(job).block(); + verify(store).desire(eq("app"), argThat(hash -> !hash.equals(before.hash()))); + verify(store, never()).complete(any(), any()); + } + + @Test + void aThousandReadsUseStoredImageWithoutRenderingAndRevocationAlsoBlocks304() { + var publications = mock(ApplicationRecordRepository.class); + var permissions = mock(ResourcePermissionService.class); + var users = mock(SessionUserService.class); + var controller = new MarketplacePreviewController(apps, publications, permissions, users, store); + when(apps.findByIdWithoutDsl("app")).thenReturn(Mono.just(app(ApplicationStatus.NORMAL, true))); + when(users.getVisitorId()).thenReturn(Mono.just("viewer")); + when(permissions.checkUserPermissionStatusOnApplication(anyString(), anyString(), any(), any())) + .thenReturn(Mono.just(UserPermissionOnResourceStatus.success(ResourcePermission.builder().resourceRole(ResourceRole.VIEWER).build()))); + when(publications.existsByApplicationId("app")).thenReturn(Mono.just(true)); + var image = new MarketplacePreview(); image.setRenderedHash("hash"); image.setImage(new byte[]{1, 2}); + when(store.read("app")).thenReturn(Mono.just(image)); + var responses = Flux.range(0, 1000).flatMap(i -> controller.image("app", null)).collectList().block(); + assertEquals(1000, responses.size()); + assertTrue(responses.stream().allMatch(response -> response.getStatusCode().value() == 200)); + assertEquals(304, controller.image("app", "\"hash\"").block().getStatusCode().value()); + when(permissions.checkUserPermissionStatusOnApplication(anyString(), anyString(), any(), any())) + .thenReturn(Mono.just(UserPermissionOnResourceStatus.anonymousUser())); + assertEquals(404, controller.image("app", "\"hash\"").block().getStatusCode().value()); + when(apps.findByIdWithoutDsl("app")).thenReturn(Mono.just(app(ApplicationStatus.NORMAL, false))); + assertEquals(404, controller.image("app", "\"hash\"").block().getStatusCode().value()); + when(apps.findByIdWithoutDsl("app")).thenReturn(Mono.just(app(ApplicationStatus.RECYCLED, true))); + assertEquals(404, controller.image("app", "\"hash\"").block().getStatusCode().value()); + verifyNoInteractions(renderer, versions, orgs); + verify(store, never()).desire(anyString(), anyString()); + verify(store, never()).claim(any()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/security/MarketplacePreviewSecurityTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/security/MarketplacePreviewSecurityTest.java new file mode 100644 index 0000000000..4714f3f316 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/security/MarketplacePreviewSecurityTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.api.framework.security; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.application.marketplace.MarketplacePreview; +import org.lowcoder.api.application.marketplace.MarketplacePreviewController; +import org.lowcoder.api.application.marketplace.MarketplacePreviewStore; +import org.lowcoder.api.authentication.request.AuthRequestFactory; +import org.lowcoder.api.authentication.service.AuthenticationApiServiceImpl; +import org.lowcoder.api.authentication.util.JWTUtils; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.application.repository.ApplicationRecordRepository; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.authentication.AuthenticationService; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.UserPermissionOnResourceStatus; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.util.CookieHelper; +import org.springframework.http.MediaType; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.ServerAuthenticationEntryPoint; +import org.springframework.security.web.server.WebFilterChainProxy; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +class MarketplacePreviewSecurityTest { + @Test + void anotherInstallationCanReadPublicImagesAnonymouslyButPrivateMarketplaceAccessStillDenies() { + var config = mock(CommonConfig.class, RETURNS_DEEP_STUBS); + when(config.getSecurity().getForbiddenEndpoints()).thenReturn(List.of()); + when(config.getSecurity().getAllCorsAllowedDomains()).thenReturn(List.of("https://trusted.test")); + var users = mock(SessionUserService.class); + when(users.resolveSessionUserFromCookie(nullable(String.class))).thenReturn(Mono.empty()); + when(users.getVisitorId()).thenReturn(Mono.just("anonymous")); + var security = new SecurityConfig(config, users, mock(UserService.class), mock(AccessDeniedHandler.class), + mock(ServerAuthenticationEntryPoint.class), mock(CookieHelper.class), mock(AuthenticationService.class), + mock(AuthenticationApiServiceImpl.class), mock(AuthRequestFactory.class), mock(JWTUtils.class)); + var apps = mock(ApplicationService.class); + var versions = mock(ApplicationRecordRepository.class); + var permissions = mock(ResourcePermissionService.class); + var store = mock(MarketplacePreviewStore.class); + when(apps.findByIdWithoutDsl("app")).thenReturn(Mono.just(Application.builder().id("app") + .publicToAll(true).publicToMarketplace(true).applicationStatus(ApplicationStatus.NORMAL).build())); + when(versions.existsByApplicationId("app")).thenReturn(Mono.just(true)); + when(permissions.checkUserPermissionStatusOnApplication(anyString(), anyString(), any(), any())) + .thenReturn(Mono.just(UserPermissionOnResourceStatus.success(ResourcePermission.builder().resourceRole(ResourceRole.VIEWER).build()))); + var image = new MarketplacePreview(); image.setRenderedHash("hash"); image.setImage(new byte[]{1, 2}); + when(store.read("app")).thenReturn(Mono.just(image)); + var client = WebTestClient.bindToController(new MarketplacePreviewController(apps, versions, permissions, users, store)) + .webFilter(new WebFilterChainProxy(security.securityWebFilterChain( + ServerHttpSecurity.http().authenticationManager(authentication -> Mono.empty())))) + .configureClient().baseUrl("https://community.test").build(); + String path = "/api/v1/applications/app/marketplace-preview/image"; + client.get().uri(path).header("Origin", "https://another-installation.test").exchange() + .expectStatus().isOk().expectHeader().contentType(MediaType.IMAGE_JPEG) + .expectHeader().valueEquals("Access-Control-Allow-Origin", "https://another-installation.test") + .expectBody(byte[].class).isEqualTo(new byte[]{1, 2}); + client.get().uri(path).header("If-None-Match", "\"hash\"").exchange().expectStatus().isNotModified(); + client.get().uri("/api/applications/app/marketplace-preview/image") + .header("Origin", "https://another-installation.test").exchange().expectStatus().isOk(); + when(permissions.checkUserPermissionStatusOnApplication(anyString(), anyString(), any(), any())) + .thenReturn(Mono.just(UserPermissionOnResourceStatus.anonymousUser())); + client.get().uri(path).header("If-None-Match", "\"hash\"").exchange().expectStatus().isNotFound(); + } +} diff --git a/server/node-service/MARKETPLACE_PREVIEWS.md b/server/node-service/MARKETPLACE_PREVIEWS.md new file mode 100644 index 0000000000..05590fcf30 --- /dev/null +++ b/server/node-service/MARKETPLACE_PREVIEWS.md @@ -0,0 +1,38 @@ +# Stored marketplace previews + +A preview belongs to the **published application at its owning installation**, not to a visitor or to a browsing session. Community previews are generated once by the Community API and served from that API to all installations. Self-hosted services generate only their own local marketplace previews. + +## Pipeline + +1. `MarketplacePreviewWorker` reconciles the locally owned marketplace catalogue at startup and every minute. It requires both sharing flags, normal application status, and an actual published version. It never falls back to draft DSL. This also backfills existing listings and detects version deletion/rollback, published module changes, and workspace theme changes. +2. The worker hashes the static published layout, resolved published modules, theme settings, and renderer version/viewport. An unchanged hash produces no new job. Query/event/preload JavaScript changes do not invalidate the static image. +3. `marketplacePreview` in the existing MongoDB holds one durable job and the last successful JPEG per app. Atomic compare-and-set updates and lease claims coordinate API replicas. A job has a unique lease token, a three-minute lease, a persisted attempt count, and backoff. A stale worker cannot overwrite a newer job or recovered lease. Rendering has a shorter 90-second request deadline. +4. The owner API sends an HMAC-authenticated request to the internal Node endpoint `POST /node-service/api/marketplace/render`. Node renders at most two jobs concurrently, with no in-memory waiting queue. Busy responses defer a job without consuming its retry budget. Other failures get at most three attempts per layout hash. Restarting services preserves completed images and resumes eligible jobs after lease expiry. +5. The API verifies the current published layout again before committing an image. A previous image remains available while an updated layout is being captured. Successful images have **no hourly expiry**; they remain until the content hash changes. +6. Browsers make a lazy image read from `GET /api/v1/applications/{id}/marketplace-preview/image` on the app's owning API. This endpoint never queues, retries, or renders work; it only checks marketplace access/publication and reads the stored JPEG. Conditional requests use an ETag and `private, no-cache`, rechecking authorization before either 200 or 304. Unsharing/recycling denies subsequent reads. These reads do not create app-view analytics events. + +**1,000 visits to an unchanged app cause zero captures.** A new or changed app is captured in the background even with zero visitors. There is no browser polling and no per-installation Community capture fallback. + +## Renderer boundaries + +The isolated `marketplace-preview.html` entry uses Lowcoder's existing components and published styles. Queries, preload JavaScript, event handlers, and data responders are removed. Chromium has no user session; requests are limited to the configured frontend's assets and official component bundles. Live data, external frames/assets, and third-party components requiring network access may not appear. Images are static layout previews, not live demos. + +Chromium runs with its sandbox enabled. Docker images include the pinned Playwright Chromium and required libraries. Container hosts must support its sandbox; do not disable it for untrusted apps. JPEG output is limited to 2 MiB and stored directly in MongoDB, below its document limit. Include this collection in normal database backups. + +## Configuration and rollout + +- `LOWCODER_NODE_SERVICE_SECRET`: the existing shared API/Node secret must match on both services. Without it, automatic generation is disabled and internal render requests are rejected. Do not expose the internal Node rendering route through a public reverse proxy. +- `LOWCODER_NODE_SERVICE_URL`: existing API-to-Node connection configuration. +- `LOWCODER_PREVIEW_FRONTEND_URL`: trusted frontend origin hosting `marketplace-preview.html`; defaults to `http://localhost:3000`, or `http://lowcoder-frontend:3000` in the multi-container configuration. +- `LOWCODER_PREVIEW_CHROMIUM_PATH`: optional local Chromium executable. Docker uses the installed Playwright browser. +- `LOWCODER_PREVIEW_RECONCILE_MS`: API catalogue reconciliation interval, default 60000 milliseconds. It controls discovery of changed layouts, not image expiration. + +Origins must be HTTP(S) origins without credentials, paths, queries, or fragments. Neither a browser nor the signed render payload can select a capture URL. Increment `MarketplacePreviewWorker.RENDER_VERSION` when a renderer/component release should regenerate otherwise unchanged layouts. + +Deploy the API, Node renderer, and frontend capture entry together on the **Community installation** to backfill shared previews centrally. Deploy the same components on each self-hosted installation for its local apps. Until an owning API has this endpoint and images, the UI shows a placeholder; opening apps remains available. No fallback to the old viewer-triggered renderer is provided. + +## Validation and local development + +`yarn test:marketplace` checks internal request authentication, render limits, payload sanitization, and asset restrictions. Java `MarketplacePreviewTest` checks read-only delivery (including 1,000 reads), revocation, static hashes, draft exclusion, and publication changes during rendering. `MarketplacePreviewSecurityTest` exercises anonymous cross-installation delivery through the real security filters and rechecks private-marketplace denial. `MarketplacePreviewMongoTest` uses disposable databases when `LOWCODER_PREVIEW_TEST_MONGO_URI` is supplied; it covers competing replicas, durable images, lease recovery, fencing, and bounded retries. + +For local renderer development, run `yarn preview:dev` with the shared secret, frontend origin, and a Chromium executable configured. It binds to loopback port 6061 and exposes only the internal POST route. Point the development API's Node URL at this port. Vite must proxy ordinary API image reads to the development API; never proxy a browser-facing capture route. diff --git a/server/node-service/package.json b/server/node-service/package.json index de4f9592bb..45238e5fa8 100644 --- a/server/node-service/package.json +++ b/server/node-service/package.json @@ -3,17 +3,19 @@ "version": "3.0.0", "private": true, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=20.0.0" }, "main": "src/server.ts", "scripts": { "dev": "nodemon src/server.ts", "debug": "nodemon --inspect src/server.ts", "start": "node ./build/server.js", - "test": "jest", + "test": "jest && yarn test:marketplace", "copy": "copyfiles -u 1 src/**/*.!(ts|test.ts) build", "genOpenApiPlugin": "npx ts-node --files ./scripts/openApiDataSourceGen.ts", - "build": "rm -rf build/ && yarn test && tsc && yarn copy" + "build": "rm -rf build/ && yarn test && tsc && yarn copy", + "test:marketplace": "node --test test/marketplacePreview.test.cjs", + "preview:dev": "node scripts/marketplace-preview-dev.cjs" }, "devDependencies": { "@types/ali-oss": "^6.16.11", @@ -70,6 +72,7 @@ "node-firebird": "^1.1.9", "openapi-types": "^12.1.3", "pino": "^9.5.0", + "playwright": "1.64.0", "prom-client": "^14.2.0", "proxy-agent": "^6.5.0", "stylis": "^4.3.0", diff --git a/server/node-service/scripts/marketplace-preview-dev.cjs b/server/node-service/scripts/marketplace-preview-dev.cjs new file mode 100644 index 0000000000..d833d134ac --- /dev/null +++ b/server/node-service/scripts/marketplace-preview-dev.cjs @@ -0,0 +1,9 @@ +// Internal renderer for frontend/API development; no browser-facing capture endpoint. +const express = require("express"); +const { createMarketplaceRenderHandler } = require("../src/services/marketplacePreview"); +const app = express(); +app.use(express.json({ limit: "12mb" })); +app.post("/node-service/api/marketplace/render", createMarketplaceRenderHandler()); +app.listen(Number(process.env.LOWCODER_PREVIEW_PORT || 6061), "127.0.0.1", () => + console.log("Internal marketplace renderer ready"), +); diff --git a/server/node-service/src/server.ts b/server/node-service/src/server.ts index 984e652178..1492433f1a 100644 --- a/server/node-service/src/server.ts +++ b/server/node-service/src/server.ts @@ -10,6 +10,7 @@ import apiRouter from "./routes/apiRouter"; import systemRouter from "./routes/systemRouter"; import cors, { CorsOptions } from "cors"; import bodyParser from "body-parser"; +import { createMarketplaceRenderHandler } from "./services/marketplacePreview"; collectDefaultMetrics(); const prefix = "/node-service"; @@ -71,6 +72,7 @@ const corsOptions: CorsOptions = { router.use(cors(corsOptions)); /** Routes */ +router.post(`${prefix}/api/marketplace/render`, createMarketplaceRenderHandler()); router.use(`${prefix}/api`, apiRouter); router.use(`${prefix}/system`, systemRouter); diff --git a/server/node-service/src/services/marketplacePreview.js b/server/node-service/src/services/marketplacePreview.js new file mode 100644 index 0000000000..969bf81b3e --- /dev/null +++ b/server/node-service/src/services/marketplacePreview.js @@ -0,0 +1,241 @@ +const { createHmac, timingSafeEqual } = require("node:crypto"); + +const ID_PATTERN = /^[a-zA-Z0-9_-]{1,80}$/; + +function configuredOrigin(value) { + const url = new URL(value); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) + throw new Error("Preview origins must be HTTP(S) origins"); + return url.origin; +} + +// No credentials, executable preload, queries or event handlers enter the renderer. +// Expressions can still evaluate the app's static component values in the isolated browser. +function staticDsl(value) { + if (!value || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(staticDsl); + const result = {}; + for (const [key, item] of Object.entries(value)) { + if (["queries", "transformers", "dataResponders", "hooks", "events"].includes(key)) + result[key] = []; + else if (key === "preload") + result[key] = Object.fromEntries( + ["css", "globalCSS"] + .filter((name) => typeof item?.[name] === "string") + .map((name) => [name, item[name]]), + ); + else result[key] = staticDsl(item); + } + return result; +} + +function capturePayload(view) { + const settings = view.orgCommonSettings || {}; + return { + applicationInfoView: { + applicationId: view.applicationInfoView.applicationId, + applicationType: view.applicationInfoView.applicationType, + name: view.applicationInfoView.name, + }, + applicationDSL: staticDsl(view.applicationDSL), + moduleDSL: staticDsl(view.moduleDSL || {}), + orgCommonSettings: { + themeList: settings.themeList || [], + ...(settings.defaultTheme ? { defaultTheme: settings.defaultTheme } : {}), + }, + }; +} + +function allowCaptureRequest(rawUrl, method, resourceType, rendererOrigin) { + if (method !== "GET") return false; + const url = new URL(rawUrl); + if (url.origin !== rendererOrigin || !["http:", "https:"].includes(url.protocol)) return false; + if (url.pathname === "/marketplace-preview.html") return resourceType === "document"; + if (url.pathname === "/__marketplace_capture_payload__") return resourceType === "fetch"; + if (!["script", "stylesheet", "font", "image", "other"].includes(resourceType)) return false; + // Official built-in component bundles are served by the existing npm asset proxy. + // No arbitrary packages, app credentials or application API calls are allowed. + if ( + resourceType === "script" && + /^\/api\/npm\/package\/none\/lowcoder-comps@(?:latest|[0-9][0-9a-zA-Z.+-]*)\/[a-zA-Z0-9_./-]+\.js$/.test( + url.pathname, + ) + ) + return !url.pathname.includes(".."); + // Only frontend bundles/assets. Never the API, proxy or node-service query routes. + return ( + /^\/(?:static\/|assets\/|src\/|node_modules\/|@(?:vite|id|fs|react-refresh)(?:\/|$))/.test( + url.pathname, + ) || /^\/[^/]+\.(?:js|css|woff2?|ttf|png|jpe?g|svg|webp|ico)$/.test(url.pathname) + ); +} + +function createChromiumCapture(rendererOrigin) { + let browserPromise; + async function getBrowser() { + if (!browserPromise) { + const { chromium } = require("playwright"); + browserPromise = chromium + .launch({ + headless: true, + executablePath: process.env.LOWCODER_PREVIEW_CHROMIUM_PATH || undefined, + // Sandbox stays enabled. Containers run this service as the lowcoder user. + chromiumSandbox: true, + args: ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"], + }) + .then((browser) => { + browser.on("disconnected", () => { + browserPromise = undefined; + }); + return browser; + }) + .catch((error) => { + browserPromise = undefined; + throw error; + }); + } + return browserPromise; + } + return async (payload) => { + const browser = await getBrowser(); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 1, + serviceWorkers: "block", + acceptDownloads: false, + reducedMotion: "reduce", + }); + const deadline = setTimeout(() => { + context.close().catch(() => {}); + }, 65000); + try { + await context.routeWebSocket("**/*", (socket) => socket.close()); + await context.route("**/*", async (route) => { + try { + const request = route.request(); + if ( + !allowCaptureRequest( + request.url(), + request.method(), + request.resourceType(), + rendererOrigin, + ) + ) + return await route.abort(); + if (new URL(request.url()).pathname === "/__marketplace_capture_payload__") + return await route.fulfill({ json: payload }); + // Do not allow an asset redirect to become an unrestricted network request. + const response = await route.fetch({ maxRedirects: 0, timeout: 15000 }); + if (response.status() >= 300 && response.status() < 400) return await route.abort(); + await route.fulfill({ response }); + } catch { + // A timed-out asset or closed context must fail this capture, not the service. + await route.abort().catch(() => {}); + } + }); + const page = await context.newPage(); + page.on("pageerror", (error) => console.warn("Capture renderer error:", error.message)); + page.on("console", (message) => { + if (message.text().startsWith("Error during remote component loading")) + console.warn("Capture component error:", message.text()); + }); + page.on("requestfailed", (request) => { + if (request.resourceType() === "script") + console.warn("Capture script unavailable:", new URL(request.url()).pathname); + }); + context.on("page", (popup) => { + if (popup !== page) popup.close().catch(() => {}); + }); + await page.goto(`${rendererOrigin}/marketplace-preview.html`, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForSelector( + '[data-marketplace-capture-ready="true"], [data-marketplace-capture-error]', + { timeout: 30000 }, + ); + if (await page.locator("[data-marketplace-capture-error]").count()) + throw new Error("App layout could not be rendered"); + await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {}); + await page.evaluate(() => + Promise.race([document.fonts.ready, new Promise((resolve) => setTimeout(resolve, 2000))]), + ); + await page.waitForFunction( + () => !document.querySelector("#marketplace-canvas .ant-skeleton"), + null, + { timeout: 20000 }, + ); + await page.waitForTimeout(1000); + if (await page.locator("[data-marketplace-capture-error]").count()) + throw new Error("App layout could not be rendered"); + return await page.screenshot({ + type: "jpeg", + quality: 78, + animations: "disabled", + timeout: 5000, + }); + } finally { + clearTimeout(deadline); + await context.close().catch(() => {}); + } + }; +} + +// Only the owning API's durable worker may request a render. Browsers never call this. +function createMarketplaceRenderHandler(options = {}) { + const secret = options.secret ?? process.env.LOWCODER_NODE_SERVICE_SECRET; + const rendererOrigin = configuredOrigin( + options.rendererOrigin || process.env.LOWCODER_PREVIEW_FRONTEND_URL || "http://localhost:3000", + ); + const capture = options.capture || createChromiumCapture(rendererOrigin); + let active = 0; + return async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + const { timestamp, payload, signature } = req.body || {}; + if (!secret) return res.status(503).json({ status: "not_configured" }); + if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() - timestamp) > 90000 || + typeof payload !== "string" || Buffer.byteLength(payload) > 10 * 1024 * 1024 || + typeof signature !== "string" || !/^[a-f0-9]{64}$/.test(signature)) + return res.status(401).json({ status: "unauthorized" }); + const expected = createHmac("sha256", secret).update(`${timestamp}\n${payload}`).digest(); + if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) + return res.status(401).json({ status: "unauthorized" }); + if (active >= 2) return res.status(429).set("Retry-After", "10").json({ status: "busy" }); + let view; + try { + view = JSON.parse(payload); + if (!ID_PATTERN.test(view.applicationInfoView?.applicationId) || + !view.applicationDSL || typeof view.applicationDSL !== "object") throw new Error(); + view = capturePayload(view); + } catch { + return res.status(400).json({ status: "invalid_payload" }); + } + active++; + try { + const bytes = await capture(view); + if (!Buffer.isBuffer(bytes) || bytes.length > 2 * 1024 * 1024 || + bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error("Invalid preview image"); + return res.status(200).type("jpeg").send(bytes); + } catch (error) { + console.warn("Marketplace render failed:", error.message); + return res.status(503).json({ status: "unavailable" }); + } finally { + active--; + } + }; +} + +module.exports = { + createMarketplaceRenderHandler, + staticDsl, + capturePayload, + allowCaptureRequest, + configuredOrigin, +}; diff --git a/server/node-service/test/marketplacePreview.test.cjs b/server/node-service/test/marketplacePreview.test.cjs new file mode 100644 index 0000000000..accec63381 --- /dev/null +++ b/server/node-service/test/marketplacePreview.test.cjs @@ -0,0 +1,143 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + createMarketplaceRenderHandler, + staticDsl, + capturePayload, + allowCaptureRequest, + configuredOrigin, +} = require("../src/services/marketplacePreview"); +const appId = "6776dd3fcdcc1d59c83f8383"; +const view = { + applicationInfoView: { applicationId: appId, applicationType: 1, name: "Example" }, + applicationDSL: { + ui: { text: "Demo" }, + queries: [{ secret: "must not render" }], + preload: { js: "fetch()" }, + }, + orgCommonSettings: { themeList: [], preloadJavaScript: "private" }, +}; +function response() { + return { + code: 200, + headers: {}, + setHeader(k, v) { + this.headers[k] = v; + }, + status(code) { + this.code = code; + return this; + }, + set(k, v) { + this.headers[k] = v; + return this; + }, + json(body) { + this.body = body; + return this; + }, + type(value) { + this.contentType = value; + return this; + }, + send(body) { + this.body = body; + return this; + }, + }; +} +const { createHmac } = require("node:crypto"); +const secret = "test-worker-secret"; +const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]); +function signed(body = view, timestamp = Date.now()) { + const payload = JSON.stringify(body); + return { timestamp, payload, signature: createHmac("sha256", secret).update(`${timestamp}\n${payload}`).digest("hex") }; +} +async function request(handler, body = signed()) { + const res = response(); + await handler({ body }, res); + return res; +} + +test("only authenticated internal worker requests can render, with bounded timestamps and payloads", async () => { + let renders = 0; + const handler = createMarketplaceRenderHandler({ secret, capture: async () => { renders++; return jpeg; } }); + for (const body of [{}, { ...signed(), signature: "0".repeat(64) }, signed(view, Date.now() - 120000), + { ...signed(), payload: "tampered" }, { ...signed(), timestamp: Infinity }]) { + assert.equal((await request(handler, body)).code, 401); + } + assert.equal(renders, 0); + assert.equal((await request(handler)).code, 200); + assert.equal(renders, 1); + assert.equal((await request(createMarketplaceRenderHandler({ secret: "", capture: async () => jpeg }))).code, 503); + assert.throws(() => configuredOrigin("file:///etc/passwd")); + assert.throws(() => configuredOrigin("https://user:pass@host.test")); +}); +test("removes executable queries/preload/events and private org settings from capture payload", () => { + const payload = capturePayload(view); + assert.deepEqual(payload.applicationDSL.queries, []); + assert.deepEqual(payload.applicationDSL.preload, {}); + assert.deepEqual(payload.orgCommonSettings, { themeList: [] }); + assert.equal(payload.applicationDSL.ui.text, "Demo"); + assert.deepEqual(staticDsl({ children: [{ events: [{ action: "write" }], label: "Keep me" }] }), { + children: [{ events: [], label: "Keep me" }], + }); + assert.equal(view.applicationDSL.queries.length, 1); + assert.deepEqual( + staticDsl({ + preload: { js: "fetch()", css: ".app { color: red }", globalCSS: "body { margin: 0 }" }, + }), + { + preload: { css: ".app { color: red }", globalCSS: "body { margin: 0 }" }, + }, + ); +}); +test("capture network only permits renderer assets; blocks APIs, sockets, redirects and third-party requests", () => { + const origin = "http://frontend:3000"; + assert.equal(allowCaptureRequest(origin + "/static/main-abc.js", "GET", "script", origin), true); + assert.equal( + allowCaptureRequest( + origin + "/api/npm/package/none/lowcoder-comps@latest/chart.js", + "GET", + "script", + origin, + ), + true, + ); + assert.equal( + allowCaptureRequest(origin + "/marketplace-preview.html", "GET", "document", origin), + true, + ); + for (const [url, method, type] of [ + [origin + "/api/v1/query", "POST", "fetch"], + [origin + "/api/delete", "GET", "image"], + ["http://169.254.169.254/latest/meta-data", "GET", "image"], + ["https://external.test/img.png", "GET", "image"], + [origin + "/apps/private", "GET", "document"], + [origin + "/static/main.js", "GET", "fetch"], + [origin + "/api/npm/package/none/untrusted@latest/index.js", "GET", "script"], + [origin + "/api/npm/package/private-app/lowcoder-comps@latest/index.js", "GET", "script"], + ]) + assert.equal(allowCaptureRequest(url, method, type, origin), false); +}); +test("renderer has no in-memory queue and bounds concurrency; durable owner retries busy jobs", async () => { + let renders = 0; + const pending = []; + const handler = createMarketplaceRenderHandler({ secret, capture: () => { + renders++; + return new Promise(resolve => pending.push(resolve)); + } }); + const first = request(handler), second = request(handler); + assert.equal((await request(handler)).code, 429); + assert.equal(renders, 2); + pending.splice(0).forEach(resolve => resolve(jpeg)); + assert.equal((await first).code, 200); + assert.equal((await second).code, 200); + const third = request(handler); + pending.shift()(jpeg); + assert.equal((await third).code, 200); +}); +test("invalid capture output is never delivered as an image", async () => { + const handler = createMarketplaceRenderHandler({ secret, capture: async () => Buffer.from("not an image") }); + assert.equal((await request(handler)).code, 503); +}); diff --git a/server/node-service/yarn.lock b/server/node-service/yarn.lock index f18b848c2f..540bd51d11 100644 --- a/server/node-service/yarn.lock +++ b/server/node-service/yarn.lock @@ -8620,6 +8620,7 @@ __metadata: nodemon: ^3.1.9 openapi-types: ^12.1.3 pino: ^9.5.0 + playwright: 1.64.0 postman-to-openapi: ^3.0.1 prom-client: ^14.2.0 proxy-agent: ^6.5.0 @@ -9702,6 +9703,26 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.64.0": + version: 1.64.0 + resolution: "playwright-core@npm:1.64.0" + bin: + playwright-core: cli.js + checksum: 60384301a8acd54dc91d8053e76616406db540b322f19f617c77ae4db8e6d3dc2f9f7b6ed0999dcbf455c65130316897c7f5c721b4591734ec237e121a322561 + languageName: node + linkType: hard + +"playwright@npm:1.64.0": + version: 1.64.0 + resolution: "playwright@npm:1.64.0" + dependencies: + playwright-core: 1.64.0 + bin: + playwright: cli.js + checksum: fa6aa08097d305bffc99613ae5484651670e2210bb9d7bfb268570d9d957feccce975b7f74fb6042f4e714e05fabcfd54166e48c3ac88f68a59e7d308e9663af + languageName: node + linkType: hard + "postman-to-openapi@npm:^3.0.1": version: 3.0.1 resolution: "postman-to-openapi@npm:3.0.1"