block-wrapper.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { View } from 'app/design/view'
  2. import Html from 'app/ui/atoms/html'
  3. import { stripTags, appSetting, isUrl } from 'app/lib/util';
  4. import { useState } from 'react';
  5. import { Block as PageBlock, BlockContent, BlockName, BlockActions, BlockHeader, BlockTitle, BlockDescription, BlockIcon } from 'app/ui/molecules/page/page-block'
  6. import { useTranslation } from 'react-i18next'
  7. import { Modal, NeoButton, NeoButtonLink } from 'app/design/controls'
  8. import { Icon } from 'app/ui/atoms/icon'
  9. import { components } from 'app/components/registry'
  10. import { usePathname } from 'app/lib/hooks/router'
  11. import { normalizeTiers, responsiveClasses } from 'app/lib/responsive-classes'
  12. function pathComparable(path) {
  13. return String(path || '').replace(/^\/+/, '').split(/[?#]/)[0]
  14. }
  15. function BlockHeaderMenu({ menu, pathname }) {
  16. const MenuItemBlockmenu = components['menu-item']['blockmenu']
  17. const current = pathComparable(pathname)
  18. const items = menu?.items
  19. if (!items?.length || !MenuItemBlockmenu) return null
  20. return (
  21. <View className="flex-row items-center gap-2">
  22. {items.map((item) => {
  23. const href = item.link || item.url || ''
  24. const onPress = typeof item.onPress === 'function' ? item.onPress : undefined
  25. const pressed = typeof item.pressed === 'boolean'
  26. ? item.pressed
  27. : Boolean(pathComparable(href) && pathComparable(href) === current)
  28. return (
  29. <MenuItemBlockmenu
  30. key={item.id || item.name}
  31. title={item.title}
  32. href={onPress ? undefined : href}
  33. pressed={pressed}
  34. disabled={item.disabled}
  35. onPress={onPress}
  36. />
  37. )
  38. })}
  39. </View>
  40. )
  41. }
  42. // `clip` is opt-in: tile/card shadows paint outside the content box, so blocks
  43. // never hide overflow by default. Inner scrollers own their own overflow.
  44. //
  45. // `overlayHeader`: for content that scrolls edge to edge under its own chrome (the AI
  46. // agent chat). The block drops its padding and clips to its own corners, and the
  47. // header is not laid out above the content: `children` is called as
  48. // `children({ header, surface })` with the usual header — icon, title, description,
  49. // actions — unpadded (null where the design box hides the title), for the content to
  50. // float over
  51. // itself, and the colour it sits on — 'card' where the block has its background,
  52. // 'background' (the page) where it has none — for fades over the content to start
  53. // from. Its gutters are then the content's to keep, inside the scroller.
  54. export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg, fullWidth, contentOnly, list, showPadding, extraProps, fill, clip = false, overlayHeader = false, children }) {
  55. const { t } = useTranslation()
  56. const [showHelp, setShowHelp] = useState(false)
  57. const pathname = usePathname()
  58. const blockMenu = block?.menu?.items?.length ? block.menu : null
  59. const renderChildren = (header = null, surface = 'background') => (typeof children === 'function' ? children({ header, surface }) : children)
  60. if (block?.designbox_id == null)
  61. return renderChildren();
  62. block.designbox_id = Number(block.designbox_id);
  63. const aNoTitle = [0, 10, 13, 3];
  64. const aNoBg = [0, 10, 14, 4];
  65. const aNoPad = [0, 4, 1, 5, 3];
  66. let bIsShowTitle = true;
  67. if (aNoTitle.indexOf(block.designbox_id) != -1) {
  68. bIsShowTitle = false;
  69. }
  70. let bIsShowBg = true;
  71. if (aNoBg.indexOf(block.designbox_id) != -1) {
  72. bIsShowBg = false;
  73. }
  74. let bIsShowPadding = true;
  75. if (aNoPad.indexOf(block.designbox_id) != -1) {
  76. bIsShowPadding = false;
  77. }
  78. // The block's App config (Studio: "Config (for App in JSON format)") can set
  79. // the design box per breakpoint, e.g. {"designbox": {"bg": ["tablet", "desktop"]}}.
  80. // Each key (bg, padding, title, rounded) lists the tiers where that part shows,
  81. // and solid the tiers where the background is the opaque card fill;
  82. // a missing key keeps designbox_id. Props from code still win below.
  83. const designbox = config?.designbox || {};
  84. if (designbox.bg != null) bIsShowBg = normalizeTiers(designbox.bg);
  85. if (designbox.padding != null) bIsShowPadding = normalizeTiers(designbox.padding);
  86. if (designbox.title != null) bIsShowTitle = normalizeTiers(designbox.title);
  87. const rounded = designbox.rounded ?? config?.rounded;
  88. const solid = designbox.solid;
  89. if (typeof showBg !== 'undefined') {
  90. bIsShowBg = showBg;
  91. }
  92. if (typeof showTitle !== 'undefined') {
  93. bIsShowTitle = showTitle;
  94. }
  95. if (typeof showPadding !== 'undefined') {
  96. bIsShowPadding = showPadding;
  97. }
  98. if (overlayHeader) bIsShowPadding = false;
  99. const cssClasses = extraProps?.cssClasses || "";
  100. // Streamlined logic: avoid unnecessary fragment, ensure BlockContent is not wrapping elements twice
  101. const content_type = config?.content_type || block?.content?.[0]?.type
  102. if ((content_type == 'browse' || content_type == 'browse_list'))
  103. contentOnly = true;
  104. if (contentOnly) {
  105. return (<View className={wrapperClassses}>
  106. {renderChildren()}
  107. </View>)
  108. }
  109. const pureHelp = stripTags(block.help);
  110. if (pureHelp) {
  111. bIsShowTitle = true;
  112. }
  113. if (blockMenu) {
  114. bIsShowTitle = true;
  115. }
  116. const isHelpLink = isUrl(pureHelp)
  117. const isHelp = !!block.help
  118. const hasHeaderActions = !!(config?.header_more_url || blockMenu)
  119. const fillClass = fill ? 'flex h-full min-h-0 flex-1 flex-col' : ''
  120. // Title on some tiers only: hidden where it's off.
  121. const titleTiersClass = Array.isArray(bIsShowTitle) ? responsiveClasses('title', bIsShowTitle) : ''
  122. const headerName = (
  123. <BlockName>
  124. <View className="flex-row items-center gap-2">
  125. {!!block.icon && <BlockIcon>
  126. <Icon icon={block.icon} size={appSetting('theme', 'blocks')['u-block-icon-size']}/>
  127. </BlockIcon>}
  128. <BlockTitle>{stripTags(block.title)}</BlockTitle></View>
  129. {!!block.description && <BlockDescription>{block.description}</BlockDescription>}
  130. </BlockName>
  131. )
  132. const headerActions = (
  133. <>
  134. {hasHeaderActions && (<BlockActions>
  135. {blockMenu ? (
  136. <BlockHeaderMenu menu={blockMenu} pathname={pathname} />
  137. ) : null}
  138. {config?.header_more_url ? (
  139. <NeoButtonLink
  140. href={config?.header_more_url}
  141. label={t(config?.header_more_text || 'See all')}
  142. style="link"
  143. borderShape="roundedRectangle"
  144. controlSize="small"
  145. />
  146. ) : null}
  147. </BlockActions>)}
  148. {(isHelp && isHelpLink) && <NeoButtonLink href={pureHelp} target="_blank" style="borderless" image="LifeBuoy" label={t('Help')} />}
  149. {(isHelp && !isHelpLink) && <NeoButton style="borderless" image="LifeBuoy" label={t('Help')} onPress={() => setShowHelp(true)} />}
  150. </>
  151. )
  152. // Plain text, like any block header: the content's fade is its chrome. (A pill is
  153. // for interactive elements.) The content positions and pads it.
  154. const overlayHeaderNode = overlayHeader && bIsShowTitle ? (
  155. <BlockHeader className={titleTiersClass || undefined} pointerEvents="box-none">
  156. {headerName}
  157. {headerActions}
  158. </BlockHeader>
  159. ) : null
  160. return (
  161. <View className={`${wrapperClassses || 'w-full'} ${fillClass}`}>
  162. {(isHelp && !isHelpLink) && <Modal title={t('Help')} onVisible={!!showHelp} onClose={() => { setShowHelp(false) }} transparent={false}>
  163. <Html data={pureHelp} />
  164. </Modal>}
  165. <View className={`@container/block w-full ${fillClass}`}>
  166. <PageBlock
  167. key={block.id}
  168. isBg={bIsShowBg}
  169. isPad={bIsShowPadding}
  170. rounded={rounded}
  171. solid={solid}
  172. className={[
  173. "w-full mx-auto",
  174. fill ? "flex h-full min-h-0 flex-1 flex-col" : "",
  175. // Content runs to the block's edges: keep it inside the rounded corners.
  176. overlayHeader ? "overflow-hidden" : "",
  177. (!fullWidth && !cssClasses.includes("max-w-") ? appSetting('layout', 'max_width_block') : ""),
  178. cssClasses,
  179. ].filter(Boolean).join(" ")}
  180. >
  181. {bIsShowTitle && !overlayHeader && (
  182. <BlockHeader
  183. isPad={bIsShowPadding}
  184. className={[
  185. fill ? 'shrink-0' : '',
  186. fill && !bIsShowPadding ? 'px-4 pt-4' : '',
  187. titleTiersClass,
  188. ].filter(Boolean).join(' ') || undefined}
  189. >
  190. {headerName}
  191. {headerActions}
  192. </BlockHeader>
  193. )}
  194. <BlockContent
  195. isPad={bIsShowPadding}
  196. className={
  197. fill
  198. ? `flex min-h-0 flex-1 flex-col ${clip ? 'overflow-hidden' : ''}`
  199. : undefined
  200. }
  201. >
  202. {/* Background on some tiers only counts as a card: it is where it shows most. */}
  203. {renderChildren(overlayHeaderNode, bIsShowBg ? 'card' : 'background')}
  204. </BlockContent>
  205. </PageBlock>
  206. </View>
  207. </View>
  208. );
  209. }