mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-11 03:37:57 +00:00
feat(web): improve skill detail overview UX and markdown highlighting
This commit is contained in:
parent
82fe91adde
commit
b7fd95459c
10 changed files with 436 additions and 11 deletions
24
web/src/features/skill/code-language.test.ts
Normal file
24
web/src/features/skill/code-language.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { inferMarkdownCodeLanguage } from './code-language'
|
||||
|
||||
describe('inferMarkdownCodeLanguage', () => {
|
||||
it('infers python code blocks', () => {
|
||||
expect(inferMarkdownCodeLanguage('from pypdf import PdfReader\nprint(\"ok\")')).toBe('python')
|
||||
})
|
||||
|
||||
it('infers bash command blocks', () => {
|
||||
expect(inferMarkdownCodeLanguage('pip install pypdf\npython script.py')).toBe('bash')
|
||||
})
|
||||
|
||||
it('infers json payloads', () => {
|
||||
expect(inferMarkdownCodeLanguage('{\n \"name\": \"skillhub\"\n}')).toBe('json')
|
||||
})
|
||||
|
||||
it('infers yaml frontmatter style snippets', () => {
|
||||
expect(inferMarkdownCodeLanguage('name: pdf\nversion: 1.0.0')).toBe('yaml')
|
||||
})
|
||||
|
||||
it('returns undefined for prose-like content', () => {
|
||||
expect(inferMarkdownCodeLanguage('This extracts all images as output files.')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
66
web/src/features/skill/code-language.ts
Normal file
66
web/src/features/skill/code-language.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { Code, Root } from 'mdast'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
const BASH_PREFIX_PATTERN = /^(?:\$ |pip3? |python3? -m |python3? |npm |pnpm |yarn |npx |git |make |curl |wget |docker(?:-compose)? |kubectl |helm |cd |ls |cat |cp |mv |rm |mkdir |chmod |export |set |echo )/m
|
||||
const PYTHON_PATTERN = /(?:^|\n)(?:from [\w.]+ import |import [\w.]+|def \w+\(|class \w+|with open\(|print\(|if __name__ == ['"]__main__['"]|for \w+ in |try:|except )/
|
||||
const SQL_PATTERN = /^(?:select|insert\s+into|update|delete\s+from|create\s+table|alter\s+table|with\s+\w+\s+as)\b/im
|
||||
const TYPESCRIPT_PATTERN = /(?:^|\n)(?:interface \w+|type \w+\s*=|import type |export type |export interface |const \w+:\s|:\s(?:string|number|boolean|Record<|Array<)|as const\b)/
|
||||
const JAVASCRIPT_PATTERN = /(?:^|\n)(?:const |let |var |function \w+\(|export default |export function |module\.exports|import .* from |=>)/
|
||||
const YAML_LINE_PATTERN = /^(\s*-\s+)?[\w"'./-]+:\s*.+$/m
|
||||
|
||||
function looksLikeJson(value: string) {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function inferMarkdownCodeLanguage(value: string): string | undefined {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (looksLikeJson(trimmed)) {
|
||||
return 'json'
|
||||
}
|
||||
|
||||
if (PYTHON_PATTERN.test(trimmed)) {
|
||||
return 'python'
|
||||
}
|
||||
|
||||
if (BASH_PREFIX_PATTERN.test(trimmed)) {
|
||||
return 'bash'
|
||||
}
|
||||
|
||||
if (SQL_PATTERN.test(trimmed)) {
|
||||
return 'sql'
|
||||
}
|
||||
|
||||
if (TYPESCRIPT_PATTERN.test(trimmed)) {
|
||||
return 'ts'
|
||||
}
|
||||
|
||||
if (JAVASCRIPT_PATTERN.test(trimmed)) {
|
||||
return 'javascript'
|
||||
}
|
||||
|
||||
if (trimmed.includes(':') && YAML_LINE_PATTERN.test(trimmed)) {
|
||||
return 'yaml'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function remarkInferCodeLanguage() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'code', (node: Code) => {
|
||||
if (!node.lang) {
|
||||
node.lang = inferMarkdownCodeLanguage(node.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import ReactMarkdown from 'react-markdown'
|
|||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import { remarkInferCodeLanguage } from './code-language'
|
||||
import { stripMarkdownFrontmatter } from './markdown-frontmatter'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
|
|
@ -12,7 +14,7 @@ interface MarkdownRendererProps {
|
|||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
const containerClassName = [
|
||||
className,
|
||||
'prose prose-sm max-w-none break-words [overflow-wrap:anywhere] dark:prose-invert',
|
||||
'max-w-none break-words text-sm text-foreground/90 [overflow-wrap:anywhere]',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
|
@ -21,12 +23,83 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
return (
|
||||
<div className={containerClassName}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
|
||||
remarkPlugins={[remarkGfm, remarkInferCodeLanguage]}
|
||||
rehypePlugins={[rehypeSanitize, [rehypeHighlight, { detect: true, ignoreMissing: true }]]}
|
||||
components={{
|
||||
p: ({ className: paragraphClassName, children, ...props }) => (
|
||||
<p className={cn('my-4 text-[15px] leading-8 text-foreground/85', paragraphClassName)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ className: linkClassName, children, ...props }) => (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-primary underline decoration-primary/30 underline-offset-4 transition-colors hover:text-primary/80',
|
||||
linkClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ className: strongClassName, children, ...props }) => (
|
||||
<strong className={cn('font-semibold text-foreground', strongClassName)} {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
h1: ({ className: headingClassName, children, ...props }) => (
|
||||
<h1
|
||||
className={cn(
|
||||
'scroll-mt-24 mb-6 border-b border-border/50 pb-4 font-heading text-3xl font-bold tracking-tight text-foreground',
|
||||
headingClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ className: headingClassName, children, ...props }) => (
|
||||
<h2
|
||||
className={cn(
|
||||
'scroll-mt-24 mt-10 mb-4 border-b border-border/40 pb-3 font-heading text-2xl font-semibold tracking-tight text-foreground',
|
||||
headingClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ className: headingClassName, children, ...props }) => (
|
||||
<h3
|
||||
className={cn(
|
||||
'scroll-mt-24 mt-8 mb-3 font-heading text-xl font-semibold tracking-tight text-foreground',
|
||||
headingClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
ul: ({ className: listClassName, children, ...props }) => (
|
||||
<ul className={cn('my-5 list-disc space-y-2 pl-6 text-foreground/85 marker:text-primary/60', listClassName)} {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ className: listClassName, children, ...props }) => (
|
||||
<ol className={cn('my-5 list-decimal space-y-2 pl-6 text-foreground/85 marker:text-primary/60', listClassName)} {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ className: itemClassName, children, ...props }) => (
|
||||
<li className={cn('pl-1 leading-7', itemClassName)} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<div className="max-w-full overflow-x-auto rounded-lg bg-muted/40 p-4">
|
||||
<pre className="m-0 min-w-max bg-transparent p-0">{children}</pre>
|
||||
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
|
||||
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
|
||||
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
code: ({ className: codeClassName, children, ...props }) => {
|
||||
|
|
@ -34,7 +107,10 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="break-words rounded bg-muted px-1 py-0.5 text-sm" {...props}>
|
||||
<code
|
||||
className="break-words rounded-md border border-border/40 bg-secondary/45 px-1.5 py-0.5 text-[0.9em] font-medium text-foreground/95"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
|
|
@ -46,11 +122,67 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
</code>
|
||||
)
|
||||
},
|
||||
blockquote: ({ className: blockquoteClassName, children, ...props }) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'relative my-6 overflow-hidden rounded-r-xl border-l-4 border-l-primary/35 bg-secondary/30 px-5 py-4 text-foreground/80 shadow-sm',
|
||||
blockquoteClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: ({ className: hrClassName, ...props }) => (
|
||||
<hr className={cn('my-10 mx-auto w-full max-w-full border-border/50', hrClassName)} {...props} />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<table>{children}</table>
|
||||
<div className="my-6 overflow-hidden rounded-2xl border border-border/80 bg-card/80 shadow-sm">
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<table className="m-0 min-w-full border-separate border-spacing-0 text-sm">{children}</table>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
thead: ({ className: sectionClassName, children, ...props }) => (
|
||||
<thead className={cn('bg-secondary/55', sectionClassName)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ className: sectionClassName, children, ...props }) => (
|
||||
<tbody className={cn('[&_tr:nth-child(even)]:bg-secondary/12', sectionClassName)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ className: rowClassName, children, ...props }) => (
|
||||
<tr className={cn('transition-colors hover:bg-secondary/25', rowClassName)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ className: cellClassName, children, ...props }) => (
|
||||
<th
|
||||
className={cn(
|
||||
'border-b border-r border-border/70 px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground last:border-r-0',
|
||||
cellClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ className: cellClassName, children, ...props }) => (
|
||||
<td
|
||||
className={cn(
|
||||
'border-b border-r border-border/60 px-4 py-3 align-top text-foreground/85 last:border-r-0',
|
||||
cellClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
img: ({ className: imageClassName, alt, ...props }) => (
|
||||
<img className={cn('w-full', imageClassName)} alt={alt ?? ''} {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{normalizedContent}
|
||||
|
|
|
|||
24
web/src/features/skill/overview-collapse.test.ts
Normal file
24
web/src/features/skill/overview-collapse.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT,
|
||||
OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO,
|
||||
getOverviewCollapseMaxHeight,
|
||||
shouldCollapseOverview,
|
||||
} from './overview-collapse'
|
||||
|
||||
describe('overview collapse helpers', () => {
|
||||
it('uses a fixed max height on desktop viewports', () => {
|
||||
expect(getOverviewCollapseMaxHeight(1280, 900)).toBe(OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT)
|
||||
})
|
||||
|
||||
it('uses a viewport-based max height on mobile viewports', () => {
|
||||
expect(getOverviewCollapseMaxHeight(375, 900)).toBe(Math.round(900 * OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO))
|
||||
})
|
||||
|
||||
it('marks overview content as collapsible only when content exceeds the threshold', () => {
|
||||
const maxHeight = getOverviewCollapseMaxHeight(1280, 900)
|
||||
|
||||
expect(shouldCollapseOverview(maxHeight, 1280, 900)).toBe(false)
|
||||
expect(shouldCollapseOverview(maxHeight + 1, 1280, 900)).toBe(true)
|
||||
})
|
||||
})
|
||||
15
web/src/features/skill/overview-collapse.ts
Normal file
15
web/src/features/skill/overview-collapse.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export const OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT = 720
|
||||
export const OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO = 0.6
|
||||
const OVERVIEW_COLLAPSE_MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function getOverviewCollapseMaxHeight(viewportWidth: number, viewportHeight: number) {
|
||||
if (viewportWidth < OVERVIEW_COLLAPSE_MOBILE_BREAKPOINT) {
|
||||
return Math.round(viewportHeight * OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO)
|
||||
}
|
||||
|
||||
return OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT
|
||||
}
|
||||
|
||||
export function shouldCollapseOverview(contentHeight: number, viewportWidth: number, viewportHeight: number) {
|
||||
return contentHeight > getOverviewCollapseMaxHeight(viewportWidth, viewportHeight)
|
||||
}
|
||||
|
|
@ -556,6 +556,8 @@
|
|||
"documentationSource": "Source: {{path}}",
|
||||
"documentationUnavailableTitle": "Documentation is unavailable",
|
||||
"documentationUnavailable": "The documentation file could not be loaded. You can still inspect the package contents in the file list.",
|
||||
"expandOverview": "Expand full overview",
|
||||
"collapseOverview": "Collapse content",
|
||||
"noDocumentationTitle": "No package documentation",
|
||||
"noDocumentationDescription": "This skill version does not include a readable overview file.",
|
||||
"noDocumentationHint": "Many skills only ship executable files. You can continue with the file list and version details below.",
|
||||
|
|
|
|||
|
|
@ -556,6 +556,8 @@
|
|||
"documentationSource": "来源:{{path}}",
|
||||
"documentationUnavailableTitle": "文档暂时不可用",
|
||||
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
|
||||
"expandOverview": "展开全文",
|
||||
"collapseOverview": "收起内容",
|
||||
"noDocumentationTitle": "这个版本没有概览文档",
|
||||
"noDocumentationDescription": "该技能版本没有包含可直接展示的 README.md 或 SKILL.md。",
|
||||
"noDocumentationHint": "很多技能包只包含可执行文件或配置文件,可以继续查看下方文件列表和版本信息。",
|
||||
|
|
|
|||
|
|
@ -390,6 +390,65 @@
|
|||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ─── Markdown code highlighting ─── */
|
||||
.hljs {
|
||||
display: block;
|
||||
color: hsl(var(--foreground) / 0.92);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: hsl(var(--muted-foreground) / 0.9);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-literal,
|
||||
.hljs-name,
|
||||
.hljs-section,
|
||||
.hljs-link {
|
||||
color: hsl(var(--primary) / 0.95);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-title,
|
||||
.hljs-attribute,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-addition {
|
||||
color: hsl(var(--accent) / 0.9);
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-built_in,
|
||||
.hljs-params {
|
||||
color: hsl(218 70% 45%);
|
||||
}
|
||||
|
||||
.hljs-function,
|
||||
.hljs-title.function_,
|
||||
.hljs-title.class_ {
|
||||
color: hsl(var(--foreground) / 0.95);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hljs-regexp,
|
||||
.hljs-subst,
|
||||
.hljs-doctag {
|
||||
color: hsl(24 85% 52%);
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: hsl(var(--destructive) / 0.88);
|
||||
}
|
||||
|
||||
/* ─── Upload zone ─── */
|
||||
.upload-zone {
|
||||
border: 2px dashed hsl(var(--border));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { FileTree } from '@/features/skill/file-tree'
|
||||
import { InstallCommand } from '@/features/skill/install-command'
|
||||
import {
|
||||
getOverviewCollapseMaxHeight,
|
||||
OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT,
|
||||
shouldCollapseOverview,
|
||||
} from '@/features/skill/overview-collapse'
|
||||
import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error'
|
||||
import { RatingInput } from '@/features/social/rating-input'
|
||||
import { StarButton } from '@/features/social/star-button'
|
||||
|
|
@ -27,6 +32,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||
import { Input } from '@/shared/ui/input'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import {
|
||||
useSkillDetail,
|
||||
useSkillVersions,
|
||||
|
|
@ -90,6 +96,11 @@ export function SkillDetailPage() {
|
|||
const [targetVersionInput, setTargetVersionInput] = useState('')
|
||||
const [diffSourceVersion, setDiffSourceVersion] = useState<string | null>(null)
|
||||
const [diffCompareVersion, setDiffCompareVersion] = useState<string | null>(null)
|
||||
const [isOverviewExpanded, setIsOverviewExpanded] = useState(false)
|
||||
const [isOverviewCollapsible, setIsOverviewCollapsible] = useState(false)
|
||||
const [overviewMaxHeight, setOverviewMaxHeight] = useState(OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT)
|
||||
const overviewContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const overviewSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
|
||||
const { user, hasRole } = useAuth()
|
||||
|
||||
|
|
@ -120,6 +131,62 @@ export function SkillDetailPage() {
|
|||
const canReport = skill?.canReport ?? true
|
||||
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!readme || typeof window === 'undefined') {
|
||||
setIsOverviewCollapsible(false)
|
||||
setIsOverviewExpanded(false)
|
||||
return
|
||||
}
|
||||
|
||||
const updateOverviewState = () => {
|
||||
if (!overviewContentRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextMaxHeight = getOverviewCollapseMaxHeight(window.innerWidth, window.innerHeight)
|
||||
const nextCollapsible = shouldCollapseOverview(
|
||||
overviewContentRef.current.scrollHeight,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
)
|
||||
|
||||
setOverviewMaxHeight(nextMaxHeight)
|
||||
setIsOverviewCollapsible(nextCollapsible)
|
||||
|
||||
if (!nextCollapsible) {
|
||||
setIsOverviewExpanded(false)
|
||||
}
|
||||
}
|
||||
|
||||
updateOverviewState()
|
||||
|
||||
window.addEventListener('resize', updateOverviewState)
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => updateOverviewState())
|
||||
: null
|
||||
|
||||
if (resizeObserver && overviewContentRef.current) {
|
||||
resizeObserver.observe(overviewContentRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateOverviewState)
|
||||
resizeObserver?.disconnect()
|
||||
}
|
||||
}, [readme])
|
||||
|
||||
const handleToggleOverview = () => {
|
||||
if (!isOverviewExpanded) {
|
||||
setIsOverviewExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsOverviewExpanded(false)
|
||||
requestAnimationFrame(() => {
|
||||
overviewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}
|
||||
|
||||
const refreshSkill = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug, 'versions'] })
|
||||
|
|
@ -506,7 +573,37 @@ export function SkillDetailPage() {
|
|||
{t('skillDetail.documentationSource', { path: documentationPath })}
|
||||
</div>
|
||||
) : null}
|
||||
<MarkdownRenderer content={readme} />
|
||||
<div ref={overviewSectionRef} className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-[max-height] duration-300 ease-out',
|
||||
!isOverviewExpanded && isOverviewCollapsible && 'rounded-2xl',
|
||||
)}
|
||||
style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined}
|
||||
>
|
||||
<div ref={overviewContentRef}>
|
||||
<MarkdownRenderer content={readme} />
|
||||
</div>
|
||||
{!isOverviewExpanded && isOverviewCollapsible ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-card via-card/95 to-transparent" />
|
||||
) : null}
|
||||
</div>
|
||||
{isOverviewCollapsible ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 rounded-full border-border/70 bg-background/90 px-5 shadow-sm backdrop-blur-sm"
|
||||
aria-expanded={isOverviewExpanded}
|
||||
onClick={handleToggleOverview}
|
||||
>
|
||||
{isOverviewExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{isOverviewExpanded ? t('skillDetail.collapseOverview') : t('skillDetail.expandOverview')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
) : readmeError ? (
|
||||
<Card className="p-8 text-center">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 150,
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue