diff --git a/web/default/src/components/html-content.tsx b/web/default/src/components/html-content.tsx new file mode 100644 index 00000000000..74b8162a996 --- /dev/null +++ b/web/default/src/components/html-content.tsx @@ -0,0 +1,33 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { cn } from '@/lib/utils' + +interface HtmlContentProps { + content: string + className?: string +} + +export function HtmlContent(props: HtmlContentProps) { + return ( +
+ ) +} diff --git a/web/default/src/components/notification-popover.tsx b/web/default/src/components/notification-popover.tsx index 530aa67e7dd..947cdd44e73 100644 --- a/web/default/src/components/notification-popover.tsx +++ b/web/default/src/components/notification-popover.tsx @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import type { TFunction } from 'i18next' import { Bell, Megaphone } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { RichContent } from '@/components/rich-content' import { getAnnouncementColorClass } from '@/lib/colors' import { formatDateTimeObject } from '@/lib/time' import { cn } from '@/lib/utils' @@ -31,7 +32,6 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/empty' -import { Markdown } from '@/components/ui/markdown' import { Popover, PopoverContent, @@ -44,6 +44,7 @@ import { Separator } from '@/components/ui/separator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' interface AnnouncementItem { + id?: number | string type?: string content?: string extra?: string @@ -72,8 +73,9 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string { const pubDate = new Date(publishDate) // If invalid date, return original string - if (isNaN(pubDate.getTime())) + if (Number.isNaN(pubDate.getTime())) { return typeof publishDate === 'string' ? publishDate : '' + } const diffMs = now.getTime() - pubDate.getTime() const diffSeconds = Math.floor(diffMs / 1000) @@ -89,26 +91,31 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string { // Return relative time based on difference if (diffSeconds < 60) return t('Just now') - if (diffMinutes < 60) + if (diffMinutes < 60) { return diffMinutes === 1 ? t('1 minute ago') : t('{{count}} minutes ago', { count: diffMinutes }) - if (diffHours < 24) + } + if (diffHours < 24) { return diffHours === 1 ? t('1 hour ago') : t('{{count}} hours ago', { count: diffHours }) - if (diffDays < 7) + } + if (diffDays < 7) { return diffDays === 1 ? t('1 day ago') : t('{{count}} days ago', { count: diffDays }) - if (diffWeeks < 4) + } + if (diffWeeks < 4) { return diffWeeks === 1 ? t('1 week ago') : t('{{count}} weeks ago', { count: diffWeeks }) - if (diffMonths < 12) + } + if (diffMonths < 12) { return diffMonths === 1 ? t('1 month ago') : t('{{count}} months ago', { count: diffMonths }) + } if (diffYears < 2) return t('1 year ago') // Over 2 years, show specific date @@ -129,6 +136,19 @@ function AnnouncementDot({ type }: { type?: string }) { ) } +function getAnnouncementRenderKey(announcement: AnnouncementItem): string { + if (announcement.id !== undefined && announcement.id !== null) { + return `id:${announcement.id}` + } + + return JSON.stringify({ + content: announcement.content ?? '', + extra: announcement.extra ?? '', + publishDate: announcement.publishDate ?? '', + type: announcement.type ?? '', + }) +} + /** * Empty state component */ @@ -184,7 +204,7 @@ function NoticeContent({ return ( - {notice} + ) } @@ -221,6 +241,7 @@ function AnnouncementsContent({
{announcements.map((item, idx) => { + const announcementKey = getAnnouncementRenderKey(item) const publishDate = item.publishDate ? new Date(item.publishDate) : null @@ -232,18 +253,18 @@ function AnnouncementsContent({ : '' return ( -
+
- {item.content || ''} +
{item.extra ? (
- {item.extra} +
) : null} diff --git a/web/default/src/components/rich-content.tsx b/web/default/src/components/rich-content.tsx new file mode 100644 index 00000000000..27299ecee7f --- /dev/null +++ b/web/default/src/components/rich-content.tsx @@ -0,0 +1,39 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { isLikelyHtml } from '@/lib/content-format' +import { HtmlContent } from '@/components/html-content' +import { Markdown } from '@/components/ui/markdown' + +interface RichContentProps { + content: string + breaks?: boolean + className?: string +} + +export function RichContent(props: RichContentProps) { + if (isLikelyHtml(props.content)) { + return + } + + return ( + + {props.content} + + ) +} diff --git a/web/default/src/components/ui/markdown.tsx b/web/default/src/components/ui/markdown.tsx index 0739b57e07b..30df268e872 100644 --- a/web/default/src/components/ui/markdown.tsx +++ b/web/default/src/components/ui/markdown.tsx @@ -24,6 +24,7 @@ import { useMemo } from 'react' import { cn } from '@/lib/utils' interface MarkdownProps { + breaks?: boolean children: string className?: string } @@ -186,13 +187,13 @@ function renderMath(source: string, displayMode: boolean): string { } function replaceEmojiShortcodes(value: string): string { - return value.replace(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => { + return value.replaceAll(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => { return emojiShortcodes[shortcode] ?? shortcode }) } function getTextUnits(value: string): number { - return Array.from(value).reduce((total, character) => { + return [...value].reduce((total, character) => { if (/\s/.test(character)) { return total + 0.5 } @@ -372,7 +373,7 @@ function renderFlowDiagram(source: string): string { const nodePositions = new Map( nodes.map((node, index) => [node.id, getFlowNodeLayout(node, index, centerX)]) ) - const lastNode = nodes.length > 0 ? nodePositions.get(nodes[nodes.length - 1].id) : undefined + const lastNode = nodes.length > 0 ? nodePositions.get(nodes.at(-1)?.id ?? '') : undefined const height = Math.max(180, (lastNode?.y ?? 64) + (lastNode?.height ?? 40) / 2 + 54) const renderedEdges = edges .map((edge) => { @@ -694,31 +695,39 @@ function addExternalLinkAttributes(html: string): string { return template.innerHTML } -function renderMarkdown(markdown: string): string { - const parsedHtml = markdownParser.parse(markdown, markdownOptions) +function renderMarkdown(markdown: string, breaks = false): string { + const parsedHtml = markdownParser.parse(markdown, { + ...markdownOptions, + breaks, + }) const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions) return addExternalLinkAttributes(html) } export function Markdown(props: MarkdownProps) { - const html = useMemo(() => renderMarkdown(props.children), [props.children]) + const html = useMemo( + () => renderMarkdown(props.children, props.breaks), + [props.breaks, props.children] + ) return (
*:first-child]:mt-0 [&>*:last-child]:mb-0', - '[overflow-wrap:anywhere] break-words', + '[overflow-wrap:anywhere]', props.className )} dangerouslySetInnerHTML={{ __html: html }} diff --git a/web/default/src/features/about/index.tsx b/web/default/src/features/about/index.tsx index 9b76172364a..6898a03ba23 100644 --- a/web/default/src/features/about/index.tsx +++ b/web/default/src/features/about/index.tsx @@ -19,24 +19,12 @@ For commercial licensing, please contact support@quantumnous.com import { useQuery } from '@tanstack/react-query' import { Construction } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { Markdown } from '@/components/ui/markdown' +import { RichContent } from '@/components/rich-content' import { Skeleton } from '@/components/ui/skeleton' import { PublicLayout } from '@/components/layout' +import { isHttpUrl } from '@/lib/content-format' import { getAboutContent } from './api' -function isValidUrl(value: string) { - try { - const url = new URL(value) - return url.protocol === 'http:' || url.protocol === 'https:' - } catch { - return false - } -} - -function isLikelyHtml(value: string) { - return /<\/?[a-z][\s\S]*>/i.test(value) -} - function EmptyAboutState() { const { t } = useTranslation() const currentYear = new Date().getFullYear() @@ -131,8 +119,7 @@ export function About() { const rawContent = data?.data?.trim() ?? '' const hasContent = rawContent.length > 0 - const isUrl = hasContent && isValidUrl(rawContent) - const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent) + const isUrl = hasContent && isHttpUrl(rawContent) if (isLoading) { return ( @@ -162,6 +149,7 @@ export function About() { src={rawContent} className='h-[calc(100vh-3.5rem)] w-full border-0' title={t('About')} + sandbox='allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts' /> ) @@ -170,16 +158,10 @@ export function About() { return (
- {isHtml ? ( -
- ) : ( - - {rawContent} - - )} +
) diff --git a/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx b/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx index 5561c7e36aa..76d31005a73 100644 --- a/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx +++ b/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx @@ -17,8 +17,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useTranslation } from 'react-i18next' +import { RichContent } from '@/components/rich-content' import { formatDateTimeObject } from '@/lib/time' -import { Markdown } from '@/components/ui/markdown' import { ScrollArea } from '@/components/ui/scroll-area' import { Dialog } from '@/components/dialog' @@ -59,7 +59,7 @@ export function AnnouncementDetailModal({ {announcement?.content && (

{t('Content')}

- {announcement.content} +
)} {announcement?.extra && ( @@ -67,9 +67,11 @@ export function AnnouncementDetailModal({

{t('Additional Information')}

- - {announcement.extra} - +
)}
diff --git a/web/default/src/features/home/hooks/use-home-page-content.ts b/web/default/src/features/home/hooks/use-home-page-content.ts index fb40c31fbd0..4ff5a1f5190 100644 --- a/web/default/src/features/home/hooks/use-home-page-content.ts +++ b/web/default/src/features/home/hooks/use-home-page-content.ts @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useEffect, useState } from 'react' import i18next from 'i18next' import { toast } from 'sonner' +import { isHttpUrl } from '@/lib/content-format' import { getHomePageContent } from '../api' import type { HomePageContentResult } from '../types' @@ -75,13 +76,7 @@ export function useHomePageContent(): HomePageContentResult { } }, []) - let isUrl = false - try { - const url = new URL(content) - isUrl = url.protocol === 'http:' || url.protocol === 'https:' - } catch { - // not a URL - } + const isUrl = isHttpUrl(content) return { content, isLoaded, isUrl } } diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx index 2c7a8f3dd77..103864d1048 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' -import { Markdown } from '@/components/ui/markdown' +import { RichContent } from '@/components/rich-content' import { PublicLayout } from '@/components/layout' import { Footer } from '@/components/layout/components/footer' import { CTA, Features, Hero, HowItWorks, Stats } from './components' @@ -41,21 +41,24 @@ export function Home() { } if (content) { + if (isUrl) { + return ( + +