mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
fix(web): render custom HTML and Markdown content consistently (#5760)
* fix(markdown): render announcement markdown consistently - support soft line breaks for announcement markdown without changing the default parser behavior. - add explicit markdown element styles so lists, tables, code blocks, and quotes render correctly when typography styles are unavailable. - apply the announcement markdown mode in both the popover and detail dialog for consistent display. * refactor(markdown): simplify fallback markdown styles - remove duplicate typography utility classes now covered by explicit markdown element fallbacks. - keep the markdown renderer behavior unchanged while reducing class noise. - modernize small helper expressions to satisfy targeted lint checks. * fix(content): render custom HTML consistently - add shared rich content rendering so custom HTML and Markdown use the same path across public pages and announcements. - reuse common URL and HTML detection instead of duplicating content format checks per page. - keep custom home content inside the standard public layout while preserving full-page iframe rendering for external URLs.
This commit is contained in:
+33
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn('prose prose-neutral dark:prose-invert max-w-none', props.className)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: props.content }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
-11
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import type { TFunction } from 'i18next'
|
import type { TFunction } from 'i18next'
|
||||||
import { Bell, Megaphone } from 'lucide-react'
|
import { Bell, Megaphone } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RichContent } from '@/components/rich-content'
|
||||||
import { getAnnouncementColorClass } from '@/lib/colors'
|
import { getAnnouncementColorClass } from '@/lib/colors'
|
||||||
import { formatDateTimeObject } from '@/lib/time'
|
import { formatDateTimeObject } from '@/lib/time'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -31,7 +32,6 @@ import {
|
|||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from '@/components/ui/empty'
|
} from '@/components/ui/empty'
|
||||||
import { Markdown } from '@/components/ui/markdown'
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -44,6 +44,7 @@ import { Separator } from '@/components/ui/separator'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
interface AnnouncementItem {
|
interface AnnouncementItem {
|
||||||
|
id?: number | string
|
||||||
type?: string
|
type?: string
|
||||||
content?: string
|
content?: string
|
||||||
extra?: string
|
extra?: string
|
||||||
@@ -72,8 +73,9 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string {
|
|||||||
const pubDate = new Date(publishDate)
|
const pubDate = new Date(publishDate)
|
||||||
|
|
||||||
// If invalid date, return original string
|
// If invalid date, return original string
|
||||||
if (isNaN(pubDate.getTime()))
|
if (Number.isNaN(pubDate.getTime())) {
|
||||||
return typeof publishDate === 'string' ? publishDate : ''
|
return typeof publishDate === 'string' ? publishDate : ''
|
||||||
|
}
|
||||||
|
|
||||||
const diffMs = now.getTime() - pubDate.getTime()
|
const diffMs = now.getTime() - pubDate.getTime()
|
||||||
const diffSeconds = Math.floor(diffMs / 1000)
|
const diffSeconds = Math.floor(diffMs / 1000)
|
||||||
@@ -89,26 +91,31 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string {
|
|||||||
|
|
||||||
// Return relative time based on difference
|
// Return relative time based on difference
|
||||||
if (diffSeconds < 60) return t('Just now')
|
if (diffSeconds < 60) return t('Just now')
|
||||||
if (diffMinutes < 60)
|
if (diffMinutes < 60) {
|
||||||
return diffMinutes === 1
|
return diffMinutes === 1
|
||||||
? t('1 minute ago')
|
? t('1 minute ago')
|
||||||
: t('{{count}} minutes ago', { count: diffMinutes })
|
: t('{{count}} minutes ago', { count: diffMinutes })
|
||||||
if (diffHours < 24)
|
}
|
||||||
|
if (diffHours < 24) {
|
||||||
return diffHours === 1
|
return diffHours === 1
|
||||||
? t('1 hour ago')
|
? t('1 hour ago')
|
||||||
: t('{{count}} hours ago', { count: diffHours })
|
: t('{{count}} hours ago', { count: diffHours })
|
||||||
if (diffDays < 7)
|
}
|
||||||
|
if (diffDays < 7) {
|
||||||
return diffDays === 1
|
return diffDays === 1
|
||||||
? t('1 day ago')
|
? t('1 day ago')
|
||||||
: t('{{count}} days ago', { count: diffDays })
|
: t('{{count}} days ago', { count: diffDays })
|
||||||
if (diffWeeks < 4)
|
}
|
||||||
|
if (diffWeeks < 4) {
|
||||||
return diffWeeks === 1
|
return diffWeeks === 1
|
||||||
? t('1 week ago')
|
? t('1 week ago')
|
||||||
: t('{{count}} weeks ago', { count: diffWeeks })
|
: t('{{count}} weeks ago', { count: diffWeeks })
|
||||||
if (diffMonths < 12)
|
}
|
||||||
|
if (diffMonths < 12) {
|
||||||
return diffMonths === 1
|
return diffMonths === 1
|
||||||
? t('1 month ago')
|
? t('1 month ago')
|
||||||
: t('{{count}} months ago', { count: diffMonths })
|
: t('{{count}} months ago', { count: diffMonths })
|
||||||
|
}
|
||||||
if (diffYears < 2) return t('1 year ago')
|
if (diffYears < 2) return t('1 year ago')
|
||||||
|
|
||||||
// Over 2 years, show specific date
|
// 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
|
* Empty state component
|
||||||
*/
|
*/
|
||||||
@@ -184,7 +204,7 @@ function NoticeContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
||||||
<Markdown>{notice}</Markdown>
|
<RichContent breaks content={notice} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -221,6 +241,7 @@ function AnnouncementsContent({
|
|||||||
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
{announcements.map((item, idx) => {
|
{announcements.map((item, idx) => {
|
||||||
|
const announcementKey = getAnnouncementRenderKey(item)
|
||||||
const publishDate = item.publishDate
|
const publishDate = item.publishDate
|
||||||
? new Date(item.publishDate)
|
? new Date(item.publishDate)
|
||||||
: null
|
: null
|
||||||
@@ -232,18 +253,18 @@ function AnnouncementsContent({
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx}>
|
<div key={announcementKey}>
|
||||||
<div className='py-3'>
|
<div className='py-3'>
|
||||||
<div className='flex items-start gap-3'>
|
<div className='flex items-start gap-3'>
|
||||||
<AnnouncementDot type={item.type} />
|
<AnnouncementDot type={item.type} />
|
||||||
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
||||||
<div className='text-sm'>
|
<div className='text-sm'>
|
||||||
<Markdown>{item.content || ''}</Markdown>
|
<RichContent breaks content={item.content || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.extra ? (
|
{item.extra ? (
|
||||||
<div className='text-muted-foreground text-xs'>
|
<div className='text-muted-foreground text-xs'>
|
||||||
<Markdown>{item.extra}</Markdown>
|
<RichContent breaks content={item.extra} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
+39
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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 <HtmlContent content={props.content} className={props.className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Markdown breaks={props.breaks} className={props.className}>
|
||||||
|
{props.content}
|
||||||
|
</Markdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
+27
-18
@@ -24,6 +24,7 @@ import { useMemo } from 'react'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
|
breaks?: boolean
|
||||||
children: string
|
children: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
@@ -186,13 +187,13 @@ function renderMath(source: string, displayMode: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function replaceEmojiShortcodes(value: string): 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
|
return emojiShortcodes[shortcode] ?? shortcode
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextUnits(value: string): number {
|
function getTextUnits(value: string): number {
|
||||||
return Array.from(value).reduce((total, character) => {
|
return [...value].reduce((total, character) => {
|
||||||
if (/\s/.test(character)) {
|
if (/\s/.test(character)) {
|
||||||
return total + 0.5
|
return total + 0.5
|
||||||
}
|
}
|
||||||
@@ -372,7 +373,7 @@ function renderFlowDiagram(source: string): string {
|
|||||||
const nodePositions = new Map(
|
const nodePositions = new Map(
|
||||||
nodes.map((node, index) => [node.id, getFlowNodeLayout(node, index, centerX)])
|
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 height = Math.max(180, (lastNode?.y ?? 64) + (lastNode?.height ?? 40) / 2 + 54)
|
||||||
const renderedEdges = edges
|
const renderedEdges = edges
|
||||||
.map((edge) => {
|
.map((edge) => {
|
||||||
@@ -694,31 +695,39 @@ function addExternalLinkAttributes(html: string): string {
|
|||||||
return template.innerHTML
|
return template.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(markdown: string): string {
|
function renderMarkdown(markdown: string, breaks = false): string {
|
||||||
const parsedHtml = markdownParser.parse(markdown, markdownOptions)
|
const parsedHtml = markdownParser.parse(markdown, {
|
||||||
|
...markdownOptions,
|
||||||
|
breaks,
|
||||||
|
})
|
||||||
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
|
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
|
||||||
|
|
||||||
return addExternalLinkAttributes(html)
|
return addExternalLinkAttributes(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown(props: MarkdownProps) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'prose prose-sm dark:prose-invert max-w-none',
|
'prose prose-sm dark:prose-invert max-w-none',
|
||||||
'prose-headings:font-semibold prose-headings:tracking-tight',
|
'[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-2xl [&_h1]:font-semibold',
|
||||||
'prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg',
|
'[&_h2]:mt-5 [&_h2]:mb-3 [&_h2]:text-xl [&_h2]:font-semibold',
|
||||||
'prose-p:leading-relaxed prose-p:my-2',
|
'[&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold',
|
||||||
'prose-a:text-primary prose-a:no-underline hover:prose-a:underline',
|
'[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:font-semibold',
|
||||||
'prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none',
|
'[&_p]:my-2 [&_p]:leading-relaxed [&_strong]:font-semibold [&_em]:italic',
|
||||||
'prose-pre:bg-muted prose-pre:border',
|
'[&_a]:text-primary [&_a]:underline hover:[&_a]:text-primary/80',
|
||||||
'prose-blockquote:border-l-primary prose-blockquote:bg-muted/50 prose-blockquote:py-1',
|
'[&_ol]:my-2 [&_ul]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5 [&_li]:my-1 [&_li]:pl-1',
|
||||||
'prose-ul:my-2 prose-ol:my-2 prose-li:my-1',
|
'[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:bg-muted/50 [&_blockquote]:py-1 [&_blockquote]:pl-4',
|
||||||
'prose-table:border prose-thead:bg-muted',
|
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono',
|
||||||
'prose-td:border prose-th:border prose-td:px-3 prose-th:px-3',
|
'[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:border [&_pre]:bg-muted [&_pre]:p-3 [&_table]:my-4 [&_table]:block [&_table]:w-full [&_table]:overflow-x-auto',
|
||||||
'prose-img:rounded-lg prose-img:shadow-sm',
|
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-sm',
|
||||||
|
'[&_thead]:bg-muted [&_th]:border [&_td]:border [&_th]:px-3 [&_td]:px-3 [&_th]:py-2 [&_td]:py-2 [&_th]:text-left',
|
||||||
|
'[&_hr]:my-6 [&_img]:my-4 [&_img]:max-w-full [&_img]:rounded-lg',
|
||||||
'[&_.katex-display]:my-4 [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden',
|
'[&_.katex-display]:my-4 [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden',
|
||||||
'[&_.markdown-page-break]:my-6 [&_.markdown-page-break]:border-dashed',
|
'[&_.markdown-page-break]:my-6 [&_.markdown-page-break]:border-dashed',
|
||||||
'[&_.markdown-diagram]:my-4 [&_.markdown-diagram]:overflow-x-auto [&_.markdown-diagram]:rounded-md [&_.markdown-diagram]:border [&_.markdown-diagram]:bg-background [&_.markdown-diagram]:p-4',
|
'[&_.markdown-diagram]:my-4 [&_.markdown-diagram]:overflow-x-auto [&_.markdown-diagram]:rounded-md [&_.markdown-diagram]:border [&_.markdown-diagram]:bg-background [&_.markdown-diagram]:p-4',
|
||||||
@@ -732,7 +741,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
'[&_.markdown-sequence-note]:fill-warning/20 [&_.markdown-sequence-note]:stroke-warning',
|
'[&_.markdown-sequence-note]:fill-warning/20 [&_.markdown-sequence-note]:stroke-warning',
|
||||||
'[&_.markdown-sequence-note-text]:fill-foreground [&_.markdown-sequence-note-text]:text-xs',
|
'[&_.markdown-sequence-note-text]:fill-foreground [&_.markdown-sequence-note-text]:text-xs',
|
||||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||||
'[overflow-wrap:anywhere] break-words',
|
'[overflow-wrap:anywhere]',
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
|||||||
+8
-26
@@ -19,24 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Construction } from 'lucide-react'
|
import { Construction } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Markdown } from '@/components/ui/markdown'
|
import { RichContent } from '@/components/rich-content'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { PublicLayout } from '@/components/layout'
|
import { PublicLayout } from '@/components/layout'
|
||||||
|
import { isHttpUrl } from '@/lib/content-format'
|
||||||
import { getAboutContent } from './api'
|
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() {
|
function EmptyAboutState() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
@@ -131,8 +119,7 @@ export function About() {
|
|||||||
|
|
||||||
const rawContent = data?.data?.trim() ?? ''
|
const rawContent = data?.data?.trim() ?? ''
|
||||||
const hasContent = rawContent.length > 0
|
const hasContent = rawContent.length > 0
|
||||||
const isUrl = hasContent && isValidUrl(rawContent)
|
const isUrl = hasContent && isHttpUrl(rawContent)
|
||||||
const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent)
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -162,6 +149,7 @@ export function About() {
|
|||||||
src={rawContent}
|
src={rawContent}
|
||||||
className='h-[calc(100vh-3.5rem)] w-full border-0'
|
className='h-[calc(100vh-3.5rem)] w-full border-0'
|
||||||
title={t('About')}
|
title={t('About')}
|
||||||
|
sandbox='allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts'
|
||||||
/>
|
/>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
@@ -170,16 +158,10 @@ export function About() {
|
|||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<PublicLayout>
|
||||||
<div className='mx-auto max-w-6xl px-4 py-8'>
|
<div className='mx-auto max-w-6xl px-4 py-8'>
|
||||||
{isHtml ? (
|
<RichContent
|
||||||
<div
|
content={rawContent}
|
||||||
className='prose prose-neutral dark:prose-invert max-w-none'
|
className='prose-neutral dark:prose-invert max-w-none'
|
||||||
dangerouslySetInnerHTML={{ __html: rawContent }}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Markdown className='prose-neutral dark:prose-invert max-w-none'>
|
|
||||||
{rawContent}
|
|
||||||
</Markdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-5
@@ -17,8 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RichContent } from '@/components/rich-content'
|
||||||
import { formatDateTimeObject } from '@/lib/time'
|
import { formatDateTimeObject } from '@/lib/time'
|
||||||
import { Markdown } from '@/components/ui/markdown'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Dialog } from '@/components/dialog'
|
import { Dialog } from '@/components/dialog'
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export function AnnouncementDetailModal({
|
|||||||
{announcement?.content && (
|
{announcement?.content && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
|
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
|
||||||
<Markdown>{announcement.content}</Markdown>
|
<RichContent breaks content={announcement.content} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{announcement?.extra && (
|
{announcement?.extra && (
|
||||||
@@ -67,9 +67,11 @@ export function AnnouncementDetailModal({
|
|||||||
<h4 className='mb-2 font-medium'>
|
<h4 className='mb-2 font-medium'>
|
||||||
{t('Additional Information')}
|
{t('Additional Information')}
|
||||||
</h4>
|
</h4>
|
||||||
<Markdown className='text-muted-foreground'>
|
<RichContent
|
||||||
{announcement.extra}
|
breaks
|
||||||
</Markdown>
|
content={announcement.extra}
|
||||||
|
className='text-muted-foreground'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import i18next from 'i18next'
|
import i18next from 'i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { isHttpUrl } from '@/lib/content-format'
|
||||||
import { getHomePageContent } from '../api'
|
import { getHomePageContent } from '../api'
|
||||||
import type { HomePageContentResult } from '../types'
|
import type { HomePageContentResult } from '../types'
|
||||||
|
|
||||||
@@ -75,13 +76,7 @@ export function useHomePageContent(): HomePageContentResult {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
let isUrl = false
|
const isUrl = isHttpUrl(content)
|
||||||
try {
|
|
||||||
const url = new URL(content)
|
|
||||||
isUrl = url.protocol === 'http:' || url.protocol === 'https:'
|
|
||||||
} catch {
|
|
||||||
// not a URL
|
|
||||||
}
|
|
||||||
|
|
||||||
return { content, isLoaded, isUrl }
|
return { content, isLoaded, isUrl }
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-15
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuthStore } from '@/stores/auth-store'
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
import { Markdown } from '@/components/ui/markdown'
|
import { RichContent } from '@/components/rich-content'
|
||||||
import { PublicLayout } from '@/components/layout'
|
import { PublicLayout } from '@/components/layout'
|
||||||
import { Footer } from '@/components/layout/components/footer'
|
import { Footer } from '@/components/layout/components/footer'
|
||||||
import { CTA, Features, Hero, HowItWorks, Stats } from './components'
|
import { CTA, Features, Hero, HowItWorks, Stats } from './components'
|
||||||
@@ -41,21 +41,24 @@ export function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
|
if (isUrl) {
|
||||||
|
return (
|
||||||
|
<PublicLayout showMainContainer={false}>
|
||||||
|
<iframe
|
||||||
|
src={content}
|
||||||
|
className='h-screen w-full border-none'
|
||||||
|
title={t('Custom Home Page')}
|
||||||
|
sandbox='allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts'
|
||||||
|
/>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout showMainContainer={false}>
|
<PublicLayout>
|
||||||
<main className='overflow-x-hidden'>
|
<div className='mx-auto max-w-6xl px-4 py-8'>
|
||||||
{isUrl ? (
|
<RichContent content={content} className='custom-home-content' />
|
||||||
<iframe
|
</div>
|
||||||
src={content}
|
|
||||||
className='h-screen w-full border-none'
|
|
||||||
title={t('Custom Home Page')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className='container mx-auto py-8'>
|
|
||||||
<Markdown className='custom-home-content'>{content}</Markdown>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-26
@@ -19,11 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { FileWarning } from 'lucide-react'
|
import { FileWarning } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RichContent } from '@/components/rich-content'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Markdown } from '@/components/ui/markdown'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { PublicLayout } from '@/components/layout'
|
import { PublicLayout } from '@/components/layout'
|
||||||
|
import { isHttpUrl } from '@/lib/content-format'
|
||||||
import type { LegalDocumentResponse } from './types'
|
import type { LegalDocumentResponse } from './types'
|
||||||
|
|
||||||
type LegalDocumentProps = {
|
type LegalDocumentProps = {
|
||||||
@@ -33,19 +34,6 @@ type LegalDocumentProps = {
|
|||||||
emptyMessage: string
|
emptyMessage: string
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LegalDocument({
|
export function LegalDocument({
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -61,8 +49,7 @@ export function LegalDocument({
|
|||||||
|
|
||||||
const rawContent = data?.data?.trim() ?? ''
|
const rawContent = data?.data?.trim() ?? ''
|
||||||
const hasContent = rawContent.length > 0
|
const hasContent = rawContent.length > 0
|
||||||
const isUrl = hasContent && isValidUrl(rawContent)
|
const isUrl = hasContent && isHttpUrl(rawContent)
|
||||||
const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent)
|
|
||||||
const success = data?.success ?? false
|
const success = data?.success ?? false
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -139,16 +126,10 @@ export function LegalDocument({
|
|||||||
<h1 className='text-3xl font-semibold tracking-tight'>{title}</h1>
|
<h1 className='text-3xl font-semibold tracking-tight'>{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isHtml ? (
|
<RichContent
|
||||||
<div
|
content={rawContent}
|
||||||
className='prose prose-neutral dark:prose-invert max-w-none'
|
className='prose-neutral dark:prose-invert max-w-none'
|
||||||
dangerouslySetInnerHTML={{ __html: rawContent }}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Markdown className='prose-neutral dark:prose-invert max-w-none'>
|
|
||||||
{rawContent}
|
|
||||||
</Markdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
export function isHttpUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value)
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLikelyHtml(value: string): boolean {
|
||||||
|
return /<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|<style[\s>]|<script[\s>]|<\/?[a-z][\s\S]*>/i.test(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user