document.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { useEffect, useState } from 'react'
  2. import { Linking, Platform } from 'react-native'
  3. import { Row, View } from 'app/design/view'
  4. import { H1C, Text } from 'app/design/typography'
  5. import { appSetting, openExternalLink, setClipboard } from 'app/lib/util'
  6. import Image from 'app/ui/atoms/image'
  7. import { NeoButtonLink } from 'app/design/controls'
  8. import Badge from 'app/ui/molecules/profile/badge'
  9. import DropdownMenu from 'app/ui/atoms/dropdown-menu'
  10. import { Icon } from 'app/ui/atoms/icon'
  11. import Markdown from 'app/ui/atoms/markdown'
  12. import Menu from 'app/components/menu'
  13. import { BlockWrapper } from 'app/components/block-wrapper'
  14. import Time from 'app/ui/atoms/time'
  15. import { getWikiBlockPayload } from './helpers'
  16. import { useTranslation } from 'react-i18next'
  17. const unaStoragePathPattern = /^\/?sys_[^/]+_files\//
  18. const markdownSourceStoragePrefix = 'wiki-markdown-source:v1:'
  19. const defaultAiPrompt = 'Read this documentation page, so I can ask questions about it:\n\n{url}'
  20. const markdownBaseMenuItems = [
  21. {
  22. id: 'copy-markdown',
  23. icon: 'Copy',
  24. title: 'Copy as Markdown',
  25. },
  26. {
  27. id: 'view-markdown',
  28. icon: 'ExternalLink',
  29. title: 'View as Markdown',
  30. },
  31. ]
  32. function fallbackPlatformLabel(value) {
  33. return String(value || '')
  34. .replace(/[-_]+/g, ' ')
  35. .replace(/\b\w/g, (character) => character.toUpperCase())
  36. }
  37. function resolveIconUrl(value) {
  38. const source = String(value || '')
  39. return unaStoragePathPattern.test(source)
  40. ? `/s/${source.replace(/^\/+/, '')}`
  41. : source
  42. }
  43. function resolvePageUrl(value) {
  44. const source = String(value || '')
  45. if (/^https?:\/\//i.test(source)) return source
  46. if (Platform.OS === 'web' && typeof window !== 'undefined') {
  47. const pathname = source
  48. ? (source.startsWith('/') ? source : `/${source}`)
  49. : window.location.pathname
  50. return new URL(pathname, window.location.origin).toString()
  51. }
  52. const appUrl = String(appSetting('config', 'app_url') || '').replace(/\/$/, '')
  53. return appUrl && source ? `${appUrl}/${source.replace(/^\/+/, '')}` : source
  54. }
  55. function createAiPrompt(template, pageUrl) {
  56. return String(template || '')
  57. .split('{url}')
  58. .join(pageUrl)
  59. }
  60. function createAiAppUrl(template, prompt, pageUrl) {
  61. return String(template || '')
  62. .split('{prompt}')
  63. .join(encodeURIComponent(prompt))
  64. .split('{url}')
  65. .join(encodeURIComponent(pageUrl))
  66. }
  67. async function openAiApp(url) {
  68. if (/^https?:\/\//i.test(url)) {
  69. await openExternalLink(url)
  70. return
  71. }
  72. await Linking.openURL(url)
  73. }
  74. async function copyMarkdownSource(markdownSource) {
  75. try {
  76. await setClipboard(markdownSource)
  77. return
  78. } catch {
  79. if (Platform.OS !== 'web' || typeof document === 'undefined') return
  80. }
  81. const textarea = document.createElement('textarea')
  82. textarea.value = markdownSource
  83. textarea.setAttribute('readonly', '')
  84. textarea.style.left = '-9999px'
  85. textarea.style.position = 'fixed'
  86. document.body.appendChild(textarea)
  87. textarea.select()
  88. try {
  89. document.execCommand('copy')
  90. } finally {
  91. textarea.remove()
  92. }
  93. }
  94. // Icons already fetched this session — skip the skeleton on remount so
  95. // returning to a cached wiki page doesn't flash a placeholder.
  96. const loadedIconSrcs = new Set()
  97. function WikiDocumentIcon({ alt, size, src }) {
  98. const [loaded, setLoaded] = useState(() => loadedIconSrcs.has(src))
  99. useEffect(() => {
  100. setLoaded(loadedIconSrcs.has(src))
  101. }, [src])
  102. const handleLoad = () => {
  103. loadedIconSrcs.add(src)
  104. setLoaded(true)
  105. }
  106. return (
  107. <View className="relative rounded-lg overflow-hidden h-10 w-10 lg:h-12 lg:w-12 bg-muted">
  108. <Image
  109. alt={alt}
  110. className="h-full w-full"
  111. contentFit="contain"
  112. fill
  113. nobg
  114. onLoad={handleLoad}
  115. src={src}
  116. />
  117. </View>
  118. )
  119. }
  120. export function WikiDocumentHeader({ markdownSource = '', metadata, pageUrl = '' }) {
  121. const { t } = useTranslation()
  122. const {
  123. description,
  124. iconUrl,
  125. packageName,
  126. platforms = [],
  127. sourceCodeUrl,
  128. tags = [],
  129. title,
  130. } = metadata || {}
  131. const config = appSetting('wiki', 'document_header') || {}
  132. const platformMap = config.platforms || {}
  133. const tagConfig = config.tag || {}
  134. const aiApps = config.ai_apps || {}
  135. const resolvedPageUrl = resolvePageUrl(pageUrl)
  136. const aiPrompt = createAiPrompt(config.ai_prompt || defaultAiPrompt, resolvedPageUrl)
  137. const markdownMenuItems = [
  138. ...markdownBaseMenuItems,
  139. ...Object.entries(aiApps).flatMap(([id, aiApp]) => (
  140. aiApp?.label && aiApp?.url
  141. ? [{
  142. id: `ai-${id}`,
  143. aiAppUrl: createAiAppUrl(aiApp.url, aiPrompt, resolvedPageUrl),
  144. icon: aiApp.icon || 'Bot',
  145. title: aiApp.label,
  146. }]
  147. : []
  148. )),
  149. ]
  150. const iconSize = Number(config.icon_size) || 56
  151. const resolvedIconUrl = resolveIconUrl(iconUrl)
  152. if (!title) return null
  153. const handleMarkdownAction = (item) => {
  154. if (item?.id === 'copy-markdown') {
  155. void copyMarkdownSource(markdownSource)
  156. return
  157. }
  158. if (item?.aiAppUrl) {
  159. void openAiApp(item.aiAppUrl).catch(() => {})
  160. return
  161. }
  162. if (item?.id !== 'view-markdown') return
  163. if (Platform.OS === 'web' && typeof window !== 'undefined') {
  164. const sourceId = globalThis.crypto?.randomUUID?.()
  165. || `${Date.now()}-${Math.random().toString(36).slice(2)}`
  166. const storageKey = `${markdownSourceStoragePrefix}${sourceId}`
  167. try {
  168. window.localStorage.setItem(
  169. storageKey,
  170. JSON.stringify({ source: markdownSource, title }),
  171. )
  172. window.open(
  173. `/wiki/markdown-source#${encodeURIComponent(storageKey)}`,
  174. '_blank',
  175. 'noopener,noreferrer',
  176. )
  177. window.setTimeout(
  178. () => window.localStorage.removeItem(storageKey),
  179. 5 * 60 * 1000,
  180. )
  181. } catch {
  182. void copyMarkdownSource(markdownSource)
  183. }
  184. return
  185. }
  186. void openExternalLink(
  187. `data:text/plain;charset=utf-8,${encodeURIComponent(markdownSource)}`,
  188. )
  189. }
  190. const platformBadges = platforms.map((platform) => {
  191. const key = String(platform).toLowerCase()
  192. const platformConfig = platformMap[key] || {}
  193. return {
  194. key,
  195. data: {
  196. color: platformConfig.color || 'neutral',
  197. icon: platformConfig.icon || 'Globe',
  198. text: platformConfig.label || fallbackPlatformLabel(platform),
  199. },
  200. }
  201. })
  202. return (
  203. <View className="w-full border-b border-border/60 gap-3 pb-2 mb-4">
  204. <Row className="items-center gap-4">
  205. {resolvedIconUrl ? (
  206. <WikiDocumentIcon
  207. alt={`${title} icon`}
  208. size={iconSize}
  209. src={resolvedIconUrl}
  210. />
  211. ) : null}
  212. <H1C isfirst islast>{title}</H1C>
  213. </Row>
  214. <View className="min-w-0 flex-1 gap-4 flex-row flex-wrap justify-between">
  215. {description ? (
  216. <Text className="text-lg leading-6 text-secondary-foreground flex-auto">
  217. {description}
  218. </Text>
  219. ) : null}
  220. {platformBadges.length > 0 || tags.length > 0 ? (
  221. <Row className="flex-wrap items-center justify-start gap-2 max-w-full min-w-0 shrink">
  222. {platformBadges.map((badge) => (
  223. <Badge
  224. key={`platform-${badge.key}`}
  225. data={{ ...badge.data }}
  226. rounded
  227. size="sm"
  228. variant="secondary"
  229. />
  230. ))}
  231. {tags.map((tag) => (
  232. <Badge
  233. key={`tag-${tag}`}
  234. data={{
  235. color: tagConfig.color || 'neutral',
  236. icon: tagConfig.icon || 'Tag',
  237. text: tag,
  238. }}
  239. rounded
  240. size="sm"
  241. variant="outline"
  242. />
  243. ))}
  244. </Row>
  245. ) : null}
  246. <Row className="flex-wrap items-center justify-between gap-2 ">
  247. {packageName ? (
  248. <Row className="flex-wrap items-center gap-1">
  249. <Icon icon="Package" size={16} className="text-secondary-foreground" />
  250. <Text className="text-sm leading-7 text-secondary-foreground">{t('Version:')}</Text>
  251. <Badge
  252. data={{
  253. color: 'neutral',
  254. text: packageName,
  255. }}
  256. rounded
  257. size="xs"
  258. variant="secondary"
  259. />
  260. </Row>
  261. ) : null}
  262. <Row className="flex-wrap items-center gap-2 ">
  263. {sourceCodeUrl ? (
  264. <NeoButtonLink
  265. accessibilityLabel={`View ${title} source code`}
  266. asExternal
  267. borderShape="capsule"
  268. className="self-start"
  269. controlSize="mini"
  270. href={sourceCodeUrl}
  271. image="Github"
  272. label={t('Source')}
  273. style="borderless"
  274. target="_blank"
  275. />
  276. ) : null}
  277. {markdownSource ? (
  278. <DropdownMenu
  279. items={markdownMenuItems}
  280. onSelect={handleMarkdownAction}
  281. buttonProps={{
  282. accessibilityLabel: 'Markdown actions',
  283. borderShape: 'capsule',
  284. controlSize: 'mini',
  285. image: 'Copy',
  286. label: 'Copy Page',
  287. style: 'borderless',
  288. }}
  289. triggerAccessibilityLabel="Markdown actions"
  290. />
  291. ) : null}
  292. </Row>
  293. </Row>
  294. </View>
  295. </View>
  296. )
  297. }
  298. /** UNA-style article block: docs header + manage menu + Markdown body. */
  299. export function WikiArticleBlock({
  300. block,
  301. pageUrl,
  302. }) {
  303. const { menu, added, attributes, body, raw } = getWikiBlockPayload(block);
  304. const hasDocHeader = Boolean(attributes?.title);
  305. // Padding on mobile only: from `sm` the wiki center column pads itself,
  306. // and the block's px would push the article off the breadcrumb line.
  307. return (
  308. <BlockWrapper block={block} config={block?.config_api} showTitle={!hasDocHeader} showPadding={['mobile']}>
  309. {hasDocHeader ? (
  310. <WikiDocumentHeader
  311. markdownSource={raw}
  312. metadata={attributes}
  313. pageUrl={pageUrl}
  314. />
  315. ) : null}
  316. {body ? <Markdown data={body} /> : null}
  317. {menu?.items?.length ? (
  318. <Row className="mb-2 justify-end">
  319. <View className="w-full">
  320. <Menu
  321. {...menu}
  322. alignItems="start"
  323. autoSize
  324. autoFilter={false}
  325. params={{
  326. className: 'gap-x-2',
  327. button_variant: 'default',
  328. button_size: 'sm',
  329. button_rounded: false,
  330. button_full_width: false,
  331. show_action: true,
  332. show_counter: true,
  333. show_combined: true,
  334. }}
  335. />
  336. </View>
  337. </Row>
  338. ) : null}
  339. {added ? (
  340. <Time
  341. className="text-muted-foreground text-xs leading-5"
  342. ts={added}
  343. />
  344. ) : null}
  345. </BlockWrapper>
  346. );
  347. }