mirror of
https://github.com/langgenius/dify.git
synced 2025-12-11 10:20:57 +00:00
feat: add icon gallery stories (#28214)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
1e6d0de48b
commit
15fbedfcad
258
web/app/components/base/icons/icon-gallery.stories.tsx
Normal file
258
web/app/components/base/icons/icon-gallery.stories.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import React from 'react'
|
||||
|
||||
declare const require: any
|
||||
|
||||
type IconComponent = React.ComponentType<Record<string, unknown>>
|
||||
|
||||
type IconEntry = {
|
||||
name: string
|
||||
category: string
|
||||
path: string
|
||||
Component: IconComponent
|
||||
}
|
||||
|
||||
const iconContext = require.context('./src', true, /\.tsx$/)
|
||||
|
||||
const iconEntries: IconEntry[] = iconContext
|
||||
.keys()
|
||||
.filter((key: string) => !key.endsWith('.stories.tsx') && !key.endsWith('.spec.tsx'))
|
||||
.map((key: string) => {
|
||||
const mod = iconContext(key)
|
||||
const Component = mod.default as IconComponent | undefined
|
||||
if (!Component)
|
||||
return null
|
||||
|
||||
const relativePath = key.replace(/^\.\//, '')
|
||||
const path = `app/components/base/icons/src/${relativePath}`
|
||||
const parts = relativePath.split('/')
|
||||
const fileName = parts.pop() || ''
|
||||
const category = parts.length ? parts.join('/') : '(root)'
|
||||
const name = Component.displayName || fileName.replace(/\.tsx$/, '')
|
||||
|
||||
return {
|
||||
name,
|
||||
category,
|
||||
path,
|
||||
Component,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as IconEntry[]
|
||||
|
||||
const sortedEntries = [...iconEntries].sort((a, b) => {
|
||||
if (a.category === b.category)
|
||||
return a.name.localeCompare(b.name)
|
||||
return a.category.localeCompare(b.category)
|
||||
})
|
||||
|
||||
const filterEntries = (entries: IconEntry[], query: string) => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized)
|
||||
return entries
|
||||
|
||||
return entries.filter(entry =>
|
||||
entry.name.toLowerCase().includes(normalized)
|
||||
|| entry.path.toLowerCase().includes(normalized)
|
||||
|| entry.category.toLowerCase().includes(normalized),
|
||||
)
|
||||
}
|
||||
|
||||
const groupByCategory = (entries: IconEntry[]) => entries.reduce((acc, entry) => {
|
||||
if (!acc[entry.category])
|
||||
acc[entry.category] = []
|
||||
|
||||
acc[entry.category].push(entry)
|
||||
return acc
|
||||
}, {} as Record<string, IconEntry[]>)
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
}
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
const controlsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}
|
||||
|
||||
const searchInputStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
minWidth: 280,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d0d0d5',
|
||||
}
|
||||
|
||||
const toggleButtonStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d0d0d5',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
const emptyTextStyle: React.CSSProperties = { color: '#5f5f66' }
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}
|
||||
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
border: '1px solid #e1e1e8',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
minHeight: 140,
|
||||
}
|
||||
|
||||
const previewBaseStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 48,
|
||||
borderRadius: 6,
|
||||
}
|
||||
|
||||
const nameButtonBaseStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
font: 'inherit',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
}
|
||||
|
||||
const PREVIEW_SIZE = 40
|
||||
|
||||
const IconGalleryStory = () => {
|
||||
const [query, setQuery] = React.useState('')
|
||||
const [copiedPath, setCopiedPath] = React.useState<string | null>(null)
|
||||
const [previewTheme, setPreviewTheme] = React.useState<'light' | 'dark'>('light')
|
||||
|
||||
const filtered = React.useMemo(() => filterEntries(sortedEntries, query), [query])
|
||||
|
||||
const grouped = React.useMemo(() => groupByCategory(filtered), [filtered])
|
||||
|
||||
const categoryOrder = React.useMemo(
|
||||
() => Object.keys(grouped).sort((a, b) => a.localeCompare(b)),
|
||||
[grouped],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copiedPath)
|
||||
return undefined
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
setCopiedPath(null)
|
||||
}, 1200)
|
||||
|
||||
return () => window.clearTimeout(timerId)
|
||||
}, [copiedPath])
|
||||
|
||||
const handleCopy = React.useCallback((text: string) => {
|
||||
navigator.clipboard?.writeText(text)
|
||||
.then(() => {
|
||||
setCopiedPath(text)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to copy icon path:', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<header style={headerStyle}>
|
||||
<h1 style={{ margin: 0 }}>Icon Gallery</h1>
|
||||
<p style={{ margin: 0, color: '#5f5f66' }}>
|
||||
Browse all icon components sourced from <code>app/components/base/icons/src</code>. Use the search bar
|
||||
to filter by name or path.
|
||||
</p>
|
||||
<div style={controlsStyle}>
|
||||
<input
|
||||
style={searchInputStyle}
|
||||
placeholder="Search icons"
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
<span style={{ color: '#5f5f66' }}>{filtered.length} icons</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewTheme(prev => (prev === 'light' ? 'dark' : 'light'))}
|
||||
style={toggleButtonStyle}
|
||||
>
|
||||
Toggle {previewTheme === 'light' ? 'dark' : 'light'} preview
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{categoryOrder.length === 0 && (
|
||||
<p style={emptyTextStyle}>No icons match the current filter.</p>
|
||||
)}
|
||||
{categoryOrder.map(category => (
|
||||
<section key={category} style={sectionStyle}>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{category}</h2>
|
||||
<div style={gridStyle}>
|
||||
{grouped[category].map(entry => (
|
||||
<div key={entry.path} style={cardStyle}>
|
||||
<div
|
||||
style={{
|
||||
...previewBaseStyle,
|
||||
background: previewTheme === 'dark' ? '#1f2024' : '#fff',
|
||||
}}
|
||||
>
|
||||
<entry.Component style={{ width: PREVIEW_SIZE, height: PREVIEW_SIZE }} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(entry.path)}
|
||||
style={{
|
||||
...nameButtonBaseStyle,
|
||||
color: copiedPath === entry.path ? '#00754a' : '#24262c',
|
||||
}}
|
||||
>
|
||||
{copiedPath === entry.path ? 'Copied!' : entry.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta: Meta<typeof IconGalleryStory> = {
|
||||
title: 'Base/Icons/Icon Gallery',
|
||||
component: IconGalleryStory,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof IconGalleryStory>
|
||||
|
||||
export const All: Story = {
|
||||
render: () => <IconGalleryStory />,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user