mirror of https://github.com/langgenius/dify.git
chore: add more stories (#27403)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
23b49b8304
commit
f092bc1912
|
|
@ -0,0 +1,83 @@
|
|||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import { useAppForm } from '@/app/components/base/form'
|
||||
|
||||
type UseAppFormOptions = Parameters<typeof useAppForm>[0]
|
||||
type AppFormInstance = ReturnType<typeof useAppForm>
|
||||
|
||||
type FormStoryWrapperProps = {
|
||||
options?: UseAppFormOptions
|
||||
children: (form: AppFormInstance) => ReactNode
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export const FormStoryWrapper = ({
|
||||
options,
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
}: FormStoryWrapperProps) => {
|
||||
const [lastSubmitted, setLastSubmitted] = useState<unknown>(null)
|
||||
const [submitCount, setSubmitCount] = useState(0)
|
||||
|
||||
const form = useAppForm({
|
||||
...options,
|
||||
onSubmit: (context) => {
|
||||
setSubmitCount(count => count + 1)
|
||||
setLastSubmitted(context.value)
|
||||
options?.onSubmit?.(context)
|
||||
},
|
||||
})
|
||||
|
||||
const values = useStore(form.store, state => state.values)
|
||||
const isSubmitting = useStore(form.store, state => state.isSubmitting)
|
||||
const canSubmit = useStore(form.store, state => state.canSubmit)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
|
||||
<div className="flex-1 space-y-4">
|
||||
{(title || subtitle) && (
|
||||
<header className="space-y-1">
|
||||
{title && <h3 className="text-lg font-semibold text-text-primary">{title}</h3>}
|
||||
{subtitle && <p className="text-sm text-text-tertiary">{subtitle}</p>}
|
||||
</header>
|
||||
)}
|
||||
{children(form)}
|
||||
</div>
|
||||
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
|
||||
<div className="flex items-center justify-between text-[11px] uppercase tracking-wide text-text-tertiary">
|
||||
<span>Form State</span>
|
||||
<span>{submitCount} submit{submitCount === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<dl className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between rounded-md bg-components-button-tertiary-bg px-2 py-1">
|
||||
<dt className="font-medium text-text-secondary">isSubmitting</dt>
|
||||
<dd className="font-mono text-[11px] text-text-primary">{String(isSubmitting)}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md bg-components-button-tertiary-bg px-2 py-1">
|
||||
<dt className="font-medium text-text-secondary">canSubmit</dt>
|
||||
<dd className="font-mono text-[11px] text-text-primary">{String(canSubmit)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-text-secondary">Current Values</div>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
|
||||
{JSON.stringify(values, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-text-secondary">Last Submission</div>
|
||||
<pre className="max-h-40 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
|
||||
{lastSubmitted ? JSON.stringify(lastSubmitted, null, 2) : '—'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type FormStoryRender = (form: AppFormInstance) => ReactNode
|
||||
|
|
@ -3,7 +3,7 @@ import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShar
|
|||
import ActionButton, { ActionButtonState } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Button/ActionButton',
|
||||
title: 'Base/General/ActionButton',
|
||||
component: ActionButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import AgentLogModal from '.'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { AgentLogDetailResponse } from '@/models/log'
|
||||
|
||||
const MOCK_RESPONSE: AgentLogDetailResponse = {
|
||||
meta: {
|
||||
status: 'finished',
|
||||
executor: 'Agent Runner',
|
||||
start_time: '2024-03-12T10:00:00Z',
|
||||
elapsed_time: 12.45,
|
||||
total_tokens: 2589,
|
||||
agent_mode: 'ReACT',
|
||||
iterations: 2,
|
||||
error: undefined,
|
||||
},
|
||||
iterations: [
|
||||
{
|
||||
created_at: '2024-03-12T10:00:05Z',
|
||||
files: [],
|
||||
thought: JSON.stringify({ reasoning: 'Summarise conversation' }, null, 2),
|
||||
tokens: 934,
|
||||
tool_calls: [
|
||||
{
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
tool_input: { query: 'Latest revenue numbers' },
|
||||
tool_output: { answer: 'Revenue up 12% QoQ' },
|
||||
tool_name: 'search',
|
||||
tool_label: {
|
||||
'en-US': 'Revenue Search',
|
||||
},
|
||||
time_cost: 1.8,
|
||||
},
|
||||
],
|
||||
tool_raw: {
|
||||
inputs: JSON.stringify({ context: 'Summaries' }, null, 2),
|
||||
outputs: JSON.stringify({ observation: 'Revenue up 12% QoQ' }, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
created_at: '2024-03-12T10:00:09Z',
|
||||
files: [],
|
||||
thought: JSON.stringify({ final: 'Revenue increased 12% quarter-over-quarter.' }, null, 2),
|
||||
tokens: 642,
|
||||
tool_calls: [],
|
||||
tool_raw: {
|
||||
inputs: JSON.stringify({ context: 'Compose summary' }, null, 2),
|
||||
outputs: JSON.stringify({ observation: 'Final answer ready' }, null, 2),
|
||||
},
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
}
|
||||
|
||||
const MOCK_CHAT_ITEM: IChatItem = {
|
||||
id: 'message-1',
|
||||
content: JSON.stringify({ answer: 'Revenue grew 12% QoQ.' }, null, 2),
|
||||
input: JSON.stringify({ question: 'Summarise revenue trends.' }, null, 2),
|
||||
isAnswer: true,
|
||||
conversationId: 'conv-123',
|
||||
}
|
||||
|
||||
const AgentLogModalDemo = ({
|
||||
width = 960,
|
||||
}: {
|
||||
width?: number
|
||||
}) => {
|
||||
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
|
||||
useEffect(() => {
|
||||
setAppDetail({
|
||||
id: 'app-1',
|
||||
name: 'Analytics Agent',
|
||||
mode: 'agent-chat',
|
||||
} as any)
|
||||
|
||||
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
|
||||
|
||||
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = request.url
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
|
||||
if (parsed.pathname.endsWith('/apps/app-1/agent/logs')) {
|
||||
return new Response(JSON.stringify(MOCK_RESPONSE), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (originalFetchRef.current)
|
||||
return originalFetchRef.current(request)
|
||||
|
||||
throw new Error(`Unhandled request: ${url}`)
|
||||
}
|
||||
|
||||
globalThis.fetch = handler as typeof globalThis.fetch
|
||||
|
||||
return () => {
|
||||
if (originalFetchRef.current)
|
||||
globalThis.fetch = originalFetchRef.current
|
||||
setAppDetail(undefined)
|
||||
}
|
||||
}, [setAppDetail])
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
|
||||
<AgentLogModal
|
||||
currentLogItem={MOCK_CHAT_ITEM}
|
||||
width={width}
|
||||
onCancel={() => {
|
||||
console.log('Agent log modal closed')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Other/AgentLogModal',
|
||||
component: AgentLogModalDemo,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Agent execution viewer showing iterations, tool calls, and metadata. Fetch responses are mocked for Storybook.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
width: 960,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AgentLogModalDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import type { ReactNode } from 'react'
|
||||
import AnswerIcon from '.'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" rx="40" ry="40" fill="%23EEF2FF"/><text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="34" font-family="Arial" fill="%233256D4">AI</text></svg>'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/AnswerIcon',
|
||||
component: AnswerIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Circular avatar used for assistant answers. Supports emoji, solid background colour, or uploaded imagery.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
icon: '🤖',
|
||||
background: '#D5F5F6',
|
||||
},
|
||||
} satisfies Meta<typeof AnswerIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const StoryWrapper = (children: ReactNode) => (
|
||||
<div className="flex items-center gap-6">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => StoryWrapper(
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon {...args} />
|
||||
</div>,
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon icon="🤖" background="#D5F5F6" />
|
||||
</div>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomEmoji: Story = {
|
||||
render: args => StoryWrapper(
|
||||
<>
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon {...args} icon="🧠" background="#FEE4E2" />
|
||||
</div>
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon {...args} icon="🛠️" background="#EEF2FF" />
|
||||
</div>
|
||||
</>,
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<div className="flex gap-4">
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon icon="🧠" background="#FEE4E2" />
|
||||
</div>
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon icon="🛠️" background="#EEF2FF" />
|
||||
</div>
|
||||
</div>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ImageIcon: Story = {
|
||||
render: args => StoryWrapper(
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon
|
||||
{...args}
|
||||
iconType="image"
|
||||
imageUrl={SAMPLE_IMAGE}
|
||||
background={undefined}
|
||||
/>
|
||||
</div>,
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AnswerIcon
|
||||
iconType="image"
|
||||
imageUrl="data:image/svg+xml;utf8,<svg ...>"
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import AppIconPicker, { type AppIconSelection } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/AppIconPicker',
|
||||
component: AppIconPicker,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/icon-picker',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AppIconPicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const AppIconPickerDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<AppIconSelection | null>(null)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Choose icon…
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary shadow-sm">
|
||||
<div className="font-medium text-text-primary">Selection preview</div>
|
||||
<pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-xs leading-tight text-text-primary">
|
||||
{selection ? JSON.stringify(selection, null, 2) : 'No icon selected yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <AppIconPickerDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<AppIconSelection | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Choose icon…</button>
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import type { ComponentProps } from 'react'
|
||||
import AppIcon from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/AppIcon',
|
||||
component: AppIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded mode, edit overlays, and multiple sizes.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
icon: '🧭',
|
||||
background: '#FFEAD5',
|
||||
size: 'medium',
|
||||
rounded: false,
|
||||
},
|
||||
} satisfies Meta<typeof AppIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-4">
|
||||
<AppIcon {...args} />
|
||||
<AppIcon {...args} rounded icon="🧠" background="#E0F2FE" />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AppIcon icon="🧭" background="#FFEAD5" />
|
||||
<AppIcon icon="🧠" background="#E0F2FE" rounded />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: (args) => {
|
||||
const sizes: Array<ComponentProps<typeof AppIcon>['size']> = ['xs', 'tiny', 'small', 'medium', 'large', 'xl', 'xxl']
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
{sizes.map(size => (
|
||||
<div key={size} className="flex flex-col items-center gap-2">
|
||||
<AppIcon {...args} size={size} icon="🚀" background="#E5DEFF" />
|
||||
<span className="text-xs uppercase text-text-tertiary">{size}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
{(['xs','tiny','small','medium','large','xl','xxl'] as const).map(size => (
|
||||
<AppIcon key={size} size={size} icon="🚀" background="#E5DEFF" />
|
||||
))}
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithEditOverlay: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-4">
|
||||
<AppIcon
|
||||
{...args}
|
||||
icon="🛠️"
|
||||
background="#E7F5FF"
|
||||
showEditIcon
|
||||
/>
|
||||
<AppIcon
|
||||
{...args}
|
||||
iconType="image"
|
||||
background={undefined}
|
||||
imageUrl="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='80' height='80' rx='16' fill='%23CBD5F5'/><text x='50%' y='54%' dominant-baseline='middle' text-anchor='middle' font-size='30' font-family='Arial' fill='%231f2937'>AI</text></svg>"
|
||||
showEditIcon
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AppIcon icon="🛠️" background="#E7F5FF" showEditIcon />
|
||||
<AppIcon
|
||||
iconType="image"
|
||||
imageUrl="data:image/svg+xml;utf8,<svg ...>"
|
||||
showEditIcon
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps<typeof AudioBtn>) => {
|
|||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Button/AudioBtn',
|
||||
title: 'Base/General/AudioBtn',
|
||||
component: AudioBtn,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import AudioGallery from '.'
|
||||
|
||||
const AUDIO_SOURCES = [
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/AudioGallery',
|
||||
component: AudioGallery,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'List of audio players that render waveform previews and playback controls for each source.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AudioGallery
|
||||
srcs={[
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
|
||||
]}
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
srcs: AUDIO_SOURCES,
|
||||
},
|
||||
} satisfies Meta<typeof AudioGallery>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import AutoHeightTextarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/AutoHeightTextarea',
|
||||
title: 'Base/Data Entry/AutoHeightTextarea',
|
||||
component: AutoHeightTextarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Avatar from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Avatar',
|
||||
component: Avatar,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Avatar name="Alex Doe" avatar="https://cloud.dify.ai/logo/logo.svg" size={40} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
name: 'Alex Doe',
|
||||
avatar: 'https://cloud.dify.ai/logo/logo.svg',
|
||||
size: 40,
|
||||
},
|
||||
} satisfies Meta<typeof Avatar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithFallback: Story = {
|
||||
args: {
|
||||
avatar: null,
|
||||
name: 'Fallback',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Avatar name="Fallback" avatar={null} size={40} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomSizes: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-end gap-4">
|
||||
{[24, 32, 48, 64].map(size => (
|
||||
<div key={size} className="flex flex-col items-center gap-2">
|
||||
<Avatar {...args} size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
|
||||
<span className="text-xs text-text-tertiary">{size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
{[24, 32, 48, 64].map(size => (
|
||||
<Avatar key={size} name="Size Test" size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
|
||||
))}
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Badge from '../badge'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Badge',
|
||||
component: Badge,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compact label used for statuses and counts. Supports uppercase styling and optional red corner marks.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Badge text="beta" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
text: 'beta',
|
||||
uppercase: true,
|
||||
},
|
||||
} satisfies Meta<typeof Badge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithCornerMark: Story = {
|
||||
args: {
|
||||
text: 'new',
|
||||
hasRedCornerMark: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Badge text="new" hasRedCornerMark />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomContent: Story = {
|
||||
render: args => (
|
||||
<Badge {...args} uppercase={false}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Production
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Badge uppercase={false}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Production
|
||||
</span>
|
||||
</Badge>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import BlockInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/BlockInput',
|
||||
title: 'Base/Data Entry/BlockInput',
|
||||
component: BlockInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
|
|||
import AddButton from './add-button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Button/AddButton',
|
||||
title: 'Base/General/AddButton',
|
||||
component: AddButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { RocketLaunchIcon } from '@heroicons/react/20/solid'
|
|||
import { Button } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Button/Button',
|
||||
title: 'Base/General/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
|
|||
import SyncButton from './sync-button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Button/SyncButton',
|
||||
title: 'Base/General/SyncButton',
|
||||
component: SyncButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { markdownContentSVG } from './__mocks__/markdownContentSVG'
|
|||
import Answer from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Chat/Chat Answer',
|
||||
title: 'Base/Other/Chat Answer',
|
||||
component: Answer,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import Question from './question'
|
|||
import { User } from '@/app/components/base/icons/src/public/avatar'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Chat/Chat Question',
|
||||
title: 'Base/Other/Chat Question',
|
||||
component: Question,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const createToggleItem = <T extends { id: string; checked: boolean }>(
|
|||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/Checkbox',
|
||||
title: 'Base/Data Entry/Checkbox',
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Chip, { type Item } from '.'
|
||||
|
||||
const ITEMS: Item[] = [
|
||||
{ value: 'all', name: 'All items' },
|
||||
{ value: 'active', name: 'Active' },
|
||||
{ value: 'archived', name: 'Archived' },
|
||||
{ value: 'draft', name: 'Drafts' },
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Chip',
|
||||
component: Chip,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Filter chip with dropdown panel and optional left icon. Commonly used for status pickers in toolbars.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
items: ITEMS,
|
||||
value: 'all',
|
||||
},
|
||||
} satisfies Meta<typeof Chip>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const ChipDemo = (props: React.ComponentProps<typeof Chip>) => {
|
||||
const [selection, setSelection] = useState(props.value)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Chip
|
||||
{...props}
|
||||
value={selection}
|
||||
onSelect={item => setSelection(item.value)}
|
||||
onClear={() => setSelection('all')}
|
||||
/>
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs text-text-secondary">
|
||||
Current value: <span className="font-mono text-text-primary">{selection}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <ChipDemo {...args} />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [selection, setSelection] = useState('all')
|
||||
|
||||
<Chip
|
||||
items={items}
|
||||
value={selection}
|
||||
onSelect={item => setSelection(item.value)}
|
||||
onClear={() => setSelection('all')}
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutLeftIcon: Story = {
|
||||
render: args => (
|
||||
<ChipDemo
|
||||
{...args}
|
||||
showLeftIcon={false}
|
||||
/>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Chip showLeftIcon={false} ... />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import Confirm from '.'
|
|||
import Button from '../button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Dialog/Confirm',
|
||||
title: 'Base/Feedback/Confirm',
|
||||
component: Confirm,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import ContentDialog from '.'
|
|||
type Props = React.ComponentProps<typeof ContentDialog>
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Dialog/ContentDialog',
|
||||
title: 'Base/Feedback/ContentDialog',
|
||||
component: ContentDialog,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
|
|
@ -29,6 +29,10 @@ const meta = {
|
|||
control: false,
|
||||
description: 'Invoked when the overlay/backdrop is clicked.',
|
||||
},
|
||||
children: {
|
||||
control: false,
|
||||
table: { disable: true },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
show: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import CopyFeedback, { CopyFeedbackNew } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/CopyFeedback',
|
||||
component: CopyFeedback,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Copy-to-clipboard button that shows instant feedback and a tooltip. Includes the original ActionButton wrapper and the newer ghost-button variant.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
content: 'acc-3f92fa',
|
||||
},
|
||||
} satisfies Meta<typeof CopyFeedback>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const CopyDemo = ({ content }: { content: string }) => {
|
||||
const [value] = useState(content)
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Client ID:</span>
|
||||
<span className="rounded bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-primary">{value}</span>
|
||||
<CopyFeedback content={value} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Use the new ghost variant:</span>
|
||||
<CopyFeedbackNew content={value} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <CopyDemo content={args.content} />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<CopyFeedback content="acc-3f92fa" />
|
||||
<CopyFeedbackNew content="acc-3f92fa" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import CopyIcon from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/CopyIcon',
|
||||
component: CopyIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Interactive copy-to-clipboard glyph that swaps to a checkmark once the content has been copied. Tooltips rely on the app locale.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
content: 'https://console.dify.ai/apps/12345',
|
||||
},
|
||||
} satisfies Meta<typeof CopyIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary">
|
||||
<span>Hover or click to copy the app link:</span>
|
||||
<CopyIcon {...args} />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Hover or click to copy the app link:</span>
|
||||
<CopyIcon content="https://console.dify.ai/apps/12345" />
|
||||
</div>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const InlineUsage: Story = {
|
||||
render: args => (
|
||||
<div className="space-y-3 text-sm text-text-secondary">
|
||||
<p>
|
||||
Use the copy icon inline with labels or metadata. Clicking the icon copies the value to the clipboard and shows a success tooltip.
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-text-primary">Client ID</span>
|
||||
<span className="rounded bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-secondary">acc-3f92fa</span>
|
||||
<CopyIcon {...args} content="acc-3f92fa" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<CopyIcon content="acc-3f92fa" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import CornerLabel from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/CornerLabel',
|
||||
component: CornerLabel,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Decorative label that anchors to card corners. Useful for marking “beta”, “deprecated”, or similar callouts.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<CornerLabel label="beta" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
label: 'beta',
|
||||
},
|
||||
} satisfies Meta<typeof CornerLabel>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const OnCard: Story = {
|
||||
render: args => (
|
||||
<div className="relative w-80 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<CornerLabel {...args} className="absolute right-[-1px] top-[-1px]" />
|
||||
<div className="text-sm text-text-secondary">
|
||||
Showcase how the label sits on a card header. Pair with contextual text or status information.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<div className="relative">
|
||||
<CornerLabel label="beta" className="absolute left-[-1px] top-[-1px]" />
|
||||
...card content...
|
||||
</div>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import DatePicker from './date-picker'
|
||||
import dayjs from './utils/dayjs'
|
||||
import { getDateWithTimezone } from './utils/dayjs'
|
||||
import type { DatePickerProps } from './types'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/DateAndTimePicker',
|
||||
component: DatePicker,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Combined date and time picker with timezone support. Includes shortcuts for “now”, year-month navigation, and optional time selection.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
value: getDateWithTimezone({}),
|
||||
timezone: dayjs.tz.guess(),
|
||||
needTimePicker: true,
|
||||
placeholder: 'Select schedule time',
|
||||
onChange: fn(),
|
||||
onClear: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof DatePicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const DatePickerPlayground = (props: DatePickerProps) => {
|
||||
const [value, setValue] = useState(props.value)
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-start gap-3">
|
||||
<DatePicker popupZIndexClassname="z-50"
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onClear={() => setValue(undefined)}
|
||||
/>
|
||||
<div className="w-[252px] rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs text-text-secondary">
|
||||
Selected datetime: <span className="font-mono text-text-primary">{value ? value.format() : 'undefined'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <DatePickerPlayground {...args} />,
|
||||
args: {
|
||||
...meta.args,
|
||||
needTimePicker: false,
|
||||
placeholder: 'Select due date',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [value, setValue] = useState(getDateWithTimezone({}))
|
||||
|
||||
<DatePicker
|
||||
popupZIndexClassname="z-50"
|
||||
value={value}
|
||||
timezone={dayjs.tz.guess()}
|
||||
onChange={setValue}
|
||||
onClear={() => setValue(undefined)}
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DateOnly: Story = {
|
||||
render: args => (
|
||||
<DatePickerPlayground
|
||||
{...args}
|
||||
needTimePicker={false}
|
||||
placeholder="Select due date"
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
...meta.args,
|
||||
needTimePicker: false,
|
||||
placeholder: 'Select due date',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<DatePicker needTimePicker={false} placeholder="Select due date" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
|
|||
import Dialog from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Dialog/Dialog',
|
||||
title: 'Base/Feedback/Dialog',
|
||||
component: Dialog,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
|
|
@ -130,6 +130,7 @@ export const CustomStyling: Story = {
|
|||
bodyClassName: 'bg-gray-50 rounded-xl p-5',
|
||||
footerClassName: 'justify-between px-4 pb-4 pt-4',
|
||||
titleClassName: 'text-lg text-primary-600',
|
||||
children: null,
|
||||
footer: (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">Last synced 2 minutes ago</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Divider from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Layout/Divider',
|
||||
component: Divider,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Lightweight separator supporting horizontal and vertical orientations with gradient or solid backgrounds.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Divider />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Divider>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Horizontal: Story = {}
|
||||
|
||||
export const Vertical: Story = {
|
||||
render: args => (
|
||||
<div className="flex h-20 items-center gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<span className="text-sm text-text-secondary">Filters</span>
|
||||
<Divider {...args} type="vertical" />
|
||||
<span className="text-sm text-text-secondary">Tags</span>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Divider type="vertical" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import DrawerPlus from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/DrawerPlus',
|
||||
component: DrawerPlus,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Enhanced drawer built atop the base drawer component. Provides header/foot slots, mask control, and mobile breakpoints.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof DrawerPlus>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
type DrawerPlusProps = React.ComponentProps<typeof DrawerPlus>
|
||||
|
||||
const storyBodyElement: React.JSX.Element = (
|
||||
<div className="space-y-3 p-6 text-sm text-text-secondary">
|
||||
<p>
|
||||
DrawerPlus allows rich content with sticky header/footer and responsive masking on mobile. Great for editing flows or showing execution logs.
|
||||
</p>
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs">
|
||||
Body content scrolls if it exceeds the allotted height.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DrawerPlusDemo = (props: Partial<DrawerPlusProps>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const {
|
||||
body,
|
||||
title,
|
||||
foot,
|
||||
isShow: _isShow,
|
||||
onHide: _onHide,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const resolvedBody: React.JSX.Element = body ?? storyBodyElement
|
||||
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Open drawer plus
|
||||
</button>
|
||||
|
||||
<DrawerPlus
|
||||
{...rest as Omit<DrawerPlusProps, 'isShow' | 'onHide' | 'title' | 'body' | 'foot'>}
|
||||
isShow={open}
|
||||
onHide={() => setOpen(false)}
|
||||
title={title ?? 'Workflow execution details'}
|
||||
body={resolvedBody}
|
||||
foot={foot}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <DrawerPlusDemo {...args} />,
|
||||
args: {
|
||||
isShow: false,
|
||||
onHide: fn(),
|
||||
title: 'Edit configuration',
|
||||
body: storyBodyElement,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: (args) => {
|
||||
const FooterDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Open drawer plus
|
||||
</button>
|
||||
|
||||
<DrawerPlus
|
||||
{...args}
|
||||
isShow={open}
|
||||
onHide={() => setOpen(false)}
|
||||
title={args.title ?? 'Workflow execution details'}
|
||||
body={args.body ?? (
|
||||
<div className="space-y-3 p-6 text-sm text-text-secondary">
|
||||
<p>Populate the body with scrollable content. Footer stays pinned.</p>
|
||||
</div>
|
||||
)}
|
||||
foot={
|
||||
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-components-panel-bg p-4">
|
||||
<button className="rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white">Save</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <FooterDemo />
|
||||
},
|
||||
args: {
|
||||
isShow: false,
|
||||
onHide: fn(),
|
||||
title: 'Edit configuration!',
|
||||
body: storyBodyElement,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import Drawer from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Drawer',
|
||||
component: Drawer,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Sliding panel built on Headless UI dialog primitives. Supports optional mask, custom footer, and close behaviour.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Drawer>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const DrawerDemo = (props: React.ComponentProps<typeof Drawer>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Open drawer
|
||||
</button>
|
||||
|
||||
<Drawer
|
||||
{...props}
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={props.title ?? 'Edit configuration'}
|
||||
description={props.description ?? 'Adjust settings in the side panel and save.'}
|
||||
footer={props.footer ?? undefined}
|
||||
>
|
||||
<div className="mt-4 space-y-3 text-sm text-text-secondary">
|
||||
<p>
|
||||
This example renders arbitrary content inside the drawer body. Use it for contextual forms, settings, or informational panels.
|
||||
</p>
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs">
|
||||
Content area
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <DrawerDemo {...args} />,
|
||||
args: {
|
||||
children: null,
|
||||
isOpen: false,
|
||||
onClose: fn(),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
<Drawer
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="Edit configuration"
|
||||
description="Adjust settings in the side panel and save."
|
||||
>
|
||||
...
|
||||
</Drawer>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomFooter: Story = {
|
||||
render: args => (
|
||||
<DrawerDemo
|
||||
{...args}
|
||||
footer={
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button className="rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary" onClick={() => args.onCancel?.()}>Discard</button>
|
||||
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white">Save changes</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
children: null,
|
||||
isOpen: false,
|
||||
onClose: fn(),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Drawer footer={<CustomFooter />}>
|
||||
...
|
||||
</Drawer>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import Dropdown, { type Item } from '.'
|
||||
|
||||
const PRIMARY_ITEMS: Item[] = [
|
||||
{ value: 'rename', text: 'Rename' },
|
||||
{ value: 'duplicate', text: 'Duplicate' },
|
||||
]
|
||||
|
||||
const SECONDARY_ITEMS: Item[] = [
|
||||
{ value: 'archive', text: <span className="text-text-destructive">Archive</span> },
|
||||
{ value: 'delete', text: <span className="text-text-destructive">Delete</span> },
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/Dropdown',
|
||||
component: Dropdown,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
items: PRIMARY_ITEMS,
|
||||
secondItems: SECONDARY_ITEMS,
|
||||
},
|
||||
} satisfies Meta<typeof Dropdown>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const DropdownDemo = (props: React.ComponentProps<typeof Dropdown>) => {
|
||||
const [lastAction, setLastAction] = useState<string>('None')
|
||||
|
||||
return (
|
||||
<div className="flex h-[200px] flex-col items-center justify-center gap-4">
|
||||
<Dropdown
|
||||
{...props}
|
||||
onSelect={(item) => {
|
||||
setLastAction(String(item.value))
|
||||
props.onSelect?.(item)
|
||||
}}
|
||||
/>
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-secondary">
|
||||
Last action: <span className="font-mono text-text-primary">{lastAction}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <DropdownDemo {...args} />,
|
||||
args: {
|
||||
items: PRIMARY_ITEMS,
|
||||
secondItems: SECONDARY_ITEMS,
|
||||
onSelect: fn(),
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomTrigger: Story = {
|
||||
render: args => (
|
||||
<DropdownDemo
|
||||
{...args}
|
||||
renderTrigger={open => (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-state-base-hover-alt"
|
||||
>
|
||||
Actions
|
||||
<span className={`transition-transform ${open ? 'rotate-180' : ''}`}>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
items: PRIMARY_ITEMS,
|
||||
onSelect: fn(),
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/* eslint-disable tailwindcss/classnames-order */
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Effect from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Other/Effect',
|
||||
component: Effect,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Blurred circular glow used as a decorative background accent. Combine with relatively positioned containers.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<div className="relative h-40 w-72 overflow-hidden rounded-2xl bg-background-default-subtle">
|
||||
<Effect className="top-6 left-8" />
|
||||
</div>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Effect>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => (
|
||||
<div className="relative h-40 w-72 overflow-hidden rounded-2xl border border-divider-subtle bg-background-default-subtle">
|
||||
<Effect className="top-6 left-8" />
|
||||
<Effect className="top-14 right-10 bg-util-colors-purple-brand-purple-brand-500" />
|
||||
<div className="absolute inset-x-0 bottom-4 flex justify-center text-xs text-text-secondary">
|
||||
Accent glow
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import EmojiPickerInner from './Inner'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/EmojiPickerInner',
|
||||
component: EmojiPickerInner,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof EmojiPickerInner>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const InnerDemo = () => {
|
||||
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
|
||||
|
||||
return (
|
||||
<div className="flex h-[520px] flex-col gap-4 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-lg">
|
||||
<EmojiPickerInner
|
||||
onSelect={(emoji, background) => setSelection({ emoji, background })}
|
||||
className="flex-1 overflow-hidden rounded-xl border border-divider-subtle bg-white"
|
||||
/>
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-xs text-text-secondary">
|
||||
<div className="font-medium text-text-primary">Latest selection</div>
|
||||
<pre className="mt-1 max-h-40 overflow-auto font-mono">
|
||||
{selection ? JSON.stringify(selection, null, 2) : 'Tap an emoji to set background options.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <InnerDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
|
||||
|
||||
return (
|
||||
<EmojiPickerInner onSelect={(emoji, background) => setSelection({ emoji, background })} />
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import EmojiPicker from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/EmojiPicker',
|
||||
component: EmojiPicker,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/emoji-picker',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof EmojiPicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const EmojiPickerDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Open emoji picker…
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary shadow-sm">
|
||||
<div className="font-medium text-text-primary">Selection preview</div>
|
||||
<pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-xs leading-tight text-text-primary">
|
||||
{selection ? JSON.stringify(selection, null, 2) : 'No emoji selected yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji, background) => {
|
||||
setSelection({ emoji, background })
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <EmojiPickerDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open emoji picker…</button>
|
||||
{open && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji, background) => {
|
||||
setSelection({ emoji, background })
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { FeaturesProvider } from '.'
|
||||
import NewFeaturePanel from './new-feature-panel'
|
||||
import type { Features } from './types'
|
||||
|
||||
const DEFAULT_FEATURES: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Other/FeaturesProvider',
|
||||
component: FeaturesProvider,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Zustand-backed provider used for feature toggles. Paired with `NewFeaturePanel` for workflow settings.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FeaturesProvider>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const FeaturesDemo = () => {
|
||||
const [show, setShow] = useState(true)
|
||||
const [features, setFeatures] = useState<Features>(DEFAULT_FEATURES)
|
||||
|
||||
return (
|
||||
<FeaturesProvider features={features}>
|
||||
<div className="flex h-[520px] items-center justify-center bg-background-default-subtle">
|
||||
<div className="rounded-xl border border-divider-subtle bg-components-panel-bg p-6 text-sm text-text-secondary shadow-inner">
|
||||
<div className="mb-4 font-medium text-text-primary">Feature toggles preview</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setShow(true)}
|
||||
>
|
||||
Configure features
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewFeaturePanel
|
||||
show={show}
|
||||
isChatMode
|
||||
disabled={false}
|
||||
onChange={next => setFeatures(prev => ({ ...prev, ...next }))}
|
||||
onClose={() => setShow(false)}
|
||||
/>
|
||||
</FeaturesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <FeaturesDemo />,
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import FileIcon from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/FileIcon',
|
||||
component: FileIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Maps a file extension to the appropriate SVG icon used across upload and attachment surfaces.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'text',
|
||||
description: 'File extension or identifier used to resolve the icon.',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Custom classes passed to the SVG wrapper.',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
type: 'pdf',
|
||||
className: 'h-10 w-10',
|
||||
},
|
||||
} satisfies Meta<typeof FileIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<FileIcon {...args} />
|
||||
<span className="text-sm text-text-secondary">Extension: {args.type}</span>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<FileIcon type="pdf" className="h-10 w-10" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: () => {
|
||||
const examples = ['pdf', 'docx', 'xlsx', 'csv', 'json', 'md', 'txt', 'html', 'notion', 'unknown']
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
{examples.map(type => (
|
||||
<div key={type} className="flex flex-col items-center gap-1">
|
||||
<FileIcon type={type} className="h-9 w-9" />
|
||||
<span className="text-xs uppercase text-text-tertiary">{type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
{['pdf','docx','xlsx','csv','json','md','txt','html','notion','unknown'].map(type => (
|
||||
<FileIcon key={type} type={type} className="h-9 w-9" />
|
||||
))}
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import FileImageRender from './file-image-render'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'320\' height=\'180\'><defs><linearGradient id=\'grad\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'100%\'><stop offset=\'0%\' stop-color=\'#FEE2FF\'/><stop offset=\'100%\' stop-color=\'#E0EAFF\'/></linearGradient></defs><rect width=\'320\' height=\'180\' rx=\'18\' fill=\'url(#grad)\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'24\' fill=\'#1F2937\'>Preview</text></svg>'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/FileImageRender',
|
||||
component: FileImageRender,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders image previews inside a bordered frame. Often used in upload galleries and logs.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<FileImageRender imageUrl="https://example.com/preview.png" className="h-32 w-52" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
imageUrl: SAMPLE_IMAGE,
|
||||
className: 'h-32 w-52',
|
||||
},
|
||||
} satisfies Meta<typeof FileImageRender>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { FileList } from './file-uploader-in-chat-input/file-list'
|
||||
import type { FileEntity } from './types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'160\' height=\'160\'><rect width=\'160\' height=\'160\' rx=\'16\' fill=\'#D1E9FF\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'20\' fill=\'#1F2937\'>IMG</text></svg>'
|
||||
|
||||
const filesSample: FileEntity[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Project Brief.pdf',
|
||||
size: 256000,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.document,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Design.png',
|
||||
size: 128000,
|
||||
type: 'image/png',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.image,
|
||||
base64Url: SAMPLE_IMAGE,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Voiceover.mp3',
|
||||
size: 512000,
|
||||
type: 'audio/mpeg',
|
||||
progress: 45,
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
supportFileType: SupportUploadFileTypes.audio,
|
||||
url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/FileList',
|
||||
component: FileList,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders a responsive gallery of uploaded files, handling icons, previews, and progress states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
files: filesSample,
|
||||
},
|
||||
} satisfies Meta<typeof FileList>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const FileListPlayground = (args: React.ComponentProps<typeof FileList>) => {
|
||||
const [items, setItems] = useState<FileEntity[]>(args.files || [])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<FileList
|
||||
{...args}
|
||||
files={items}
|
||||
onRemove={fileId => setItems(list => list.filter(file => file.id !== fileId))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <FileListPlayground {...args} />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [files, setFiles] = useState(initialFiles)
|
||||
|
||||
<FileList files={files} onRemove={(id) => setFiles(list => list.filter(file => file.id !== id))} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const UploadStates: Story = {
|
||||
args: {
|
||||
files: filesSample.map(file => ({ ...file, progress: file.id === '3' ? 45 : 100 })),
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import FileTypeIcon from './file-type-icon'
|
||||
import { FileAppearanceTypeEnum } from './types'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/FileTypeIcon',
|
||||
component: FileTypeIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Displays the appropriate icon and accent colour for a file appearance type. Useful in lists and attachments.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
type: FileAppearanceTypeEnum.document,
|
||||
size: 'md',
|
||||
},
|
||||
} satisfies Meta<typeof FileTypeIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: () => (
|
||||
<div className="grid grid-cols-4 gap-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
{Object.values(FileAppearanceTypeEnum).map(type => (
|
||||
<div key={type} className="flex flex-col items-center gap-2 text-xs text-text-secondary">
|
||||
<FileTypeIcon type={type} size="xl" />
|
||||
<span className="capitalize">{type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import FileUploaderInAttachmentWrapper from './index'
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { PreviewMode } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'128\' height=\'128\'><rect width=\'128\' height=\'128\' rx=\'16\' fill=\'#E0F2FE\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'18\' fill=\'#1F2937\'>IMG</text></svg>'
|
||||
|
||||
const mockFiles: FileEntity[] = [
|
||||
{
|
||||
id: 'file-1',
|
||||
name: 'Requirements.pdf',
|
||||
size: 256000,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.document,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
name: 'Interface.png',
|
||||
size: 128000,
|
||||
type: 'image/png',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.image,
|
||||
base64Url: SAMPLE_IMAGE,
|
||||
},
|
||||
{
|
||||
id: 'file-3',
|
||||
name: 'Voiceover.mp3',
|
||||
size: 512000,
|
||||
type: 'audio/mpeg',
|
||||
progress: 35,
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
supportFileType: SupportUploadFileTypes.audio,
|
||||
url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const fileConfig: FileUpload = {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: ['document', 'image', 'audio'],
|
||||
number_limits: 5,
|
||||
preview_config: { mode: PreviewMode.NewPage, file_type_list: ['pdf', 'png'] },
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/FileUploaderInAttachment',
|
||||
component: FileUploaderInAttachmentWrapper,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/uploads',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
fileConfig,
|
||||
},
|
||||
} satisfies Meta<typeof FileUploaderInAttachmentWrapper>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const AttachmentDemo = (props: React.ComponentProps<typeof FileUploaderInAttachmentWrapper>) => {
|
||||
const [files, setFiles] = useState<FileEntity[]>(mockFiles)
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="w-[320px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs">
|
||||
<FileUploaderInAttachmentWrapper
|
||||
{...props}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <AttachmentDemo {...args} />,
|
||||
args: {
|
||||
onChange: fn(),
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: args => <AttachmentDemo {...args} isDisabled />,
|
||||
args: {
|
||||
onChange: fn(),
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import FileUploaderInChatInput from '.'
|
||||
import { FileContextProvider } from '../store'
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FileList } from '../file-uploader-in-chat-input/file-list'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
|
||||
const mockFiles: FileEntity[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Dataset.csv',
|
||||
size: 64000,
|
||||
type: 'text/csv',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.document,
|
||||
},
|
||||
]
|
||||
|
||||
const chatUploadConfig: FileUpload = {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: ['image', 'document'],
|
||||
number_limits: 3,
|
||||
}
|
||||
|
||||
type ChatInputDemoProps = React.ComponentProps<typeof FileUploaderInChatInput> & {
|
||||
initialFiles?: FileEntity[]
|
||||
}
|
||||
|
||||
const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProps) => {
|
||||
const [files, setFiles] = useState<FileEntity[]>(initialFiles)
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<FileContextProvider value={files} onChange={setFiles}>
|
||||
<div className="w-[360px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<div className="mb-3 text-xs text-text-secondary">Simulated chat input</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileUploaderInChatInput {...props} />
|
||||
<div className="flex-1 rounded-lg border border-divider-subtle bg-background-default-subtle p-2 text-xs text-text-tertiary">Type a message...</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<FileList files={files} />
|
||||
</div>
|
||||
</div>
|
||||
</FileContextProvider>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/FileUploaderInChatInput',
|
||||
component: ChatInputDemo,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Attachment trigger suited for chat inputs. Demonstrates integration with the shared file store and preview list.',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/chats/demo',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
fileConfig: chatUploadConfig,
|
||||
initialFiles: mockFiles,
|
||||
},
|
||||
} satisfies Meta<typeof ChatInputDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <ChatInputDemo {...args} />,
|
||||
}
|
||||
|
||||
export const RemoteOnly: Story = {
|
||||
args: {
|
||||
fileConfig: {
|
||||
...chatUploadConfig,
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
},
|
||||
initialFiles: [],
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import FloatRightContainer from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/FloatRightContainer',
|
||||
component: FloatRightContainer,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Wrapper that renders content in a drawer on mobile and inline on desktop. Useful for responsive settings panels.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FloatRightContainer>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const ContainerDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex h-[360px] flex-col gap-4 bg-background-default-subtle p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Open panel
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isMobile}
|
||||
onChange={e => setIsMobile(e.target.checked)}
|
||||
/>
|
||||
Simulate mobile
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<FloatRightContainer
|
||||
isMobile={isMobile}
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="Responsive panel"
|
||||
description="Switch the toggle to see drawer vs inline behaviour."
|
||||
mask
|
||||
>
|
||||
<div className="rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary">
|
||||
<p className="mb-2 text-sm text-text-primary">Panel Content</p>
|
||||
<p>
|
||||
On desktop, this block renders inline when `isOpen` is true. On mobile it appears inside the drawer wrapper.
|
||||
</p>
|
||||
</div>
|
||||
</FloatRightContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <ContainerDemo />,
|
||||
args: {
|
||||
isMobile: false,
|
||||
isOpen: false,
|
||||
onClose: fn(),
|
||||
children: null,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import ContactFields from './form-scenarios/demo/contact-fields'
|
||||
import { demoFormOpts } from './form-scenarios/demo/shared-options'
|
||||
import { ContactMethods, UserSchema } from './form-scenarios/demo/types'
|
||||
import BaseForm from './components/base/base-form'
|
||||
import type { FormSchema } from './types'
|
||||
import { FormTypeEnum } from './types'
|
||||
import { type FormStoryRender, FormStoryWrapper } from '../../../../.storybook/utils/form-story-wrapper'
|
||||
import Button from '../button'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { PreviewMode } from '@/app/components/base/features/types'
|
||||
|
||||
const FormStoryHost = () => null
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/AppForm',
|
||||
component: FormStoryHost,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Helper utilities built on top of `@tanstack/react-form` that power form rendering across Dify. These stories demonstrate the `useAppForm` hook, field primitives, conditional visibility, and custom actions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FormStoryHost>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
type AppFormInstance = Parameters<FormStoryRender>[0]
|
||||
type ContactFieldsProps = React.ComponentProps<typeof ContactFields>
|
||||
type ContactFieldsFormApi = ContactFieldsProps['form']
|
||||
|
||||
type PlaygroundFormFieldsProps = {
|
||||
form: AppFormInstance
|
||||
status: string
|
||||
}
|
||||
|
||||
const PlaygroundFormFields = ({ form, status }: PlaygroundFormFieldsProps) => {
|
||||
type PlaygroundFormValues = typeof demoFormOpts.defaultValues
|
||||
const name = useStore(form.store, state => (state.values as PlaygroundFormValues).name)
|
||||
const contactFormApi = form as ContactFieldsFormApi
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full max-w-xl flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<form.AppField
|
||||
name="name"
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label="Name"
|
||||
placeholder="Start with a capital letter"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="surname"
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label="Surname"
|
||||
placeholder="Surname must be at least 3 characters"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="isAcceptingTerms"
|
||||
children={field => (
|
||||
<field.CheckboxField
|
||||
label="I accept the terms and conditions"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!!name && <ContactFields form={contactFormApi} />}
|
||||
|
||||
<form.AppForm>
|
||||
<form.Actions />
|
||||
</form.AppForm>
|
||||
|
||||
<p className="text-xs text-text-tertiary">{status}</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const FormPlayground = () => {
|
||||
const [status, setStatus] = useState('Fill in the form and submit to see results.')
|
||||
|
||||
return (
|
||||
<FormStoryWrapper
|
||||
title="Customer onboarding form"
|
||||
subtitle="Validates with zod and conditionally reveals contact preferences."
|
||||
options={{
|
||||
...demoFormOpts,
|
||||
validators: {
|
||||
onSubmit: ({ value }) => {
|
||||
const result = UserSchema.safeParse(value as typeof demoFormOpts.defaultValues)
|
||||
if (!result.success)
|
||||
return result.error.issues[0].message
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
setStatus('Successfully saved profile.')
|
||||
},
|
||||
}}
|
||||
>
|
||||
{form => <PlaygroundFormFields form={form} status={status} />}
|
||||
</FormStoryWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const mockFileUploadConfig = {
|
||||
enabled: true,
|
||||
allowed_file_extensions: ['pdf', 'png'],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
number_limits: 3,
|
||||
preview_config: {
|
||||
mode: PreviewMode.CurrentPage,
|
||||
file_type_list: ['pdf', 'png'],
|
||||
},
|
||||
}
|
||||
|
||||
const mockFieldDefaults = {
|
||||
headline: 'Dify App',
|
||||
description: 'Streamline your AI workflows with configurable building blocks.',
|
||||
category: 'workbench',
|
||||
allowNotifications: true,
|
||||
dailyLimit: 40,
|
||||
attachment: [],
|
||||
}
|
||||
|
||||
const FieldGallery = () => {
|
||||
const selectOptions = useMemo(() => [
|
||||
{ value: 'workbench', label: 'Workbench' },
|
||||
{ value: 'playground', label: 'Playground' },
|
||||
{ value: 'production', label: 'Production' },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<FormStoryWrapper
|
||||
title="Field gallery"
|
||||
subtitle="Preview the most common field primitives exposed through `form.AppField` helpers."
|
||||
options={{
|
||||
defaultValues: mockFieldDefaults,
|
||||
}}
|
||||
>
|
||||
{form => (
|
||||
<form
|
||||
className="grid w-full max-w-4xl grid-cols-1 gap-4 lg:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<form.AppField
|
||||
name="headline"
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label="Headline"
|
||||
placeholder="Name your experience"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="description"
|
||||
children={field => (
|
||||
<field.TextAreaField
|
||||
label="Description"
|
||||
placeholder="Describe what this configuration does"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="category"
|
||||
children={field => (
|
||||
<field.SelectField
|
||||
label="Category"
|
||||
options={selectOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="allowNotifications"
|
||||
children={field => (
|
||||
<field.CheckboxField label="Enable usage notifications" />
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="dailyLimit"
|
||||
children={field => (
|
||||
<field.NumberSliderField
|
||||
label="Daily session limit"
|
||||
description="Control the maximum number of runs per user each day."
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="attachment"
|
||||
children={field => (
|
||||
<field.FileUploaderField
|
||||
label="Reference materials"
|
||||
fileConfig={mockFileUploadConfig}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="lg:col-span-2">
|
||||
<form.AppForm>
|
||||
<form.Actions />
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</FormStoryWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const conditionalSchemas: FormSchema[] = [
|
||||
{
|
||||
type: FormTypeEnum.select,
|
||||
name: 'channel',
|
||||
label: 'Preferred channel',
|
||||
required: true,
|
||||
default: 'email',
|
||||
options: ContactMethods,
|
||||
},
|
||||
{
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'contactEmail',
|
||||
label: 'Email address',
|
||||
required: true,
|
||||
placeholder: 'user@example.com',
|
||||
show_on: [{ variable: 'channel', value: 'email' }],
|
||||
},
|
||||
{
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'contactPhone',
|
||||
label: 'Phone number',
|
||||
required: true,
|
||||
placeholder: '+1 555 123 4567',
|
||||
show_on: [{ variable: 'channel', value: 'phone' }],
|
||||
},
|
||||
{
|
||||
type: FormTypeEnum.boolean,
|
||||
name: 'optIn',
|
||||
label: 'Opt in to marketing messages',
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
|
||||
const ConditionalFieldsStory = () => {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({
|
||||
channel: 'email',
|
||||
optIn: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
|
||||
<div className="flex-1 rounded-xl border border-divider-subtle bg-components-panel-bg p-5 shadow-sm">
|
||||
<BaseForm
|
||||
formSchemas={conditionalSchemas}
|
||||
defaultValues={values}
|
||||
formClassName="flex flex-col gap-4"
|
||||
onChange={(field, value) => {
|
||||
setValues(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Live values</h3>
|
||||
<p className="mb-2 text-[11px] text-text-tertiary">`show_on` rules hide or reveal inputs without losing track of the form state.</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
|
||||
{JSON.stringify(values, null, 2)}
|
||||
</pre>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomActionsStory = () => {
|
||||
return (
|
||||
<FormStoryWrapper
|
||||
title="Custom footer actions"
|
||||
subtitle="Override the default submit button to add reset or secondary operations."
|
||||
options={{
|
||||
defaultValues: {
|
||||
datasetName: 'Support FAQ',
|
||||
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
|
||||
},
|
||||
validators: {
|
||||
onChange: ({ value }) => {
|
||||
const nextValues = value as { datasetName?: string }
|
||||
if (!nextValues.datasetName || nextValues.datasetName.length < 3)
|
||||
return 'Dataset name must contain at least 3 characters.'
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{form => (
|
||||
<form
|
||||
className="flex w-full max-w-xl flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<form.AppField
|
||||
name="datasetName"
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label="Dataset name"
|
||||
placeholder="Support knowledge base"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="datasetDescription"
|
||||
children={field => (
|
||||
<field.TextAreaField
|
||||
label="Description"
|
||||
placeholder="Add a helpful summary for collaborators"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppForm>
|
||||
<form.Actions
|
||||
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => appForm.reset()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
appForm.handleSubmit()
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => appForm.handleSubmit()}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</form>
|
||||
)}
|
||||
</FormStoryWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <FormPlayground />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const form = useAppForm({
|
||||
...demoFormOpts,
|
||||
validators: {
|
||||
onSubmit: ({ value }) => UserSchema.safeParse(value).success ? undefined : 'Validation failed',
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
setStatus(\`Successfully saved profile for \${value.name}\`)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form.AppField name="name">
|
||||
{field => <field.TextField label="Name" placeholder="Start with a capital letter" />}
|
||||
</form.AppField>
|
||||
<form.AppField name="surname">
|
||||
{field => <field.TextField label="Surname" />}
|
||||
</form.AppField>
|
||||
<form.AppField name="isAcceptingTerms">
|
||||
{field => <field.CheckboxField label="I accept the terms and conditions" />}
|
||||
</form.AppField>
|
||||
{!!form.store.state.values.name && <ContactFields form={form} />}
|
||||
<form.AppForm>
|
||||
<form.Actions />
|
||||
</form.AppForm>
|
||||
</form>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const FieldExplorer: Story = {
|
||||
render: () => <FieldGallery />,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/form',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
headline: 'Dify App',
|
||||
description: 'Streamline your AI workflows',
|
||||
category: 'workbench',
|
||||
allowNotifications: true,
|
||||
dailyLimit: 40,
|
||||
attachment: [],
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form className="grid grid-cols-1 gap-4 lg:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<form.AppField name="headline">
|
||||
{field => <field.TextField label="Headline" />}
|
||||
</form.AppField>
|
||||
<form.AppField name="description">
|
||||
{field => <field.TextAreaField label="Description" />}
|
||||
</form.AppField>
|
||||
<form.AppField name="category">
|
||||
{field => <field.SelectField label="Category" options={selectOptions} />}
|
||||
</form.AppField>
|
||||
<form.AppField name="allowNotifications">
|
||||
{field => <field.CheckboxField label="Enable usage notifications" />}
|
||||
</form.AppField>
|
||||
<form.AppField name="dailyLimit">
|
||||
{field => <field.NumberSliderField label="Daily session limit" min={10} max={100} step={10} />}
|
||||
</form.AppField>
|
||||
<form.AppField name="attachment">
|
||||
{field => <field.FileUploaderField label="Reference materials" fileConfig={mockFileUploadConfig} />}
|
||||
</form.AppField>
|
||||
<form.AppForm>
|
||||
<form.Actions />
|
||||
</form.AppForm>
|
||||
</form>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ConditionalVisibility: Story = {
|
||||
render: () => <ConditionalFieldsStory />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Demonstrates schema-driven visibility using `show_on` conditions rendered through the reusable `BaseForm` component.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const conditionalSchemas: FormSchema[] = [
|
||||
{ type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', options: ContactMethods },
|
||||
{ type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email', show_on: [{ variable: 'channel', value: 'email' }] },
|
||||
{ type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone', show_on: [{ variable: 'channel', value: 'phone' }] },
|
||||
{ type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages' },
|
||||
]
|
||||
|
||||
return (
|
||||
<BaseForm
|
||||
formSchemas={conditionalSchemas}
|
||||
defaultValues={{ channel: 'email', optIn: false }}
|
||||
formClassName="flex flex-col gap-4"
|
||||
onChange={(field, value) => setValues(prev => ({ ...prev, [field]: value }))}
|
||||
/>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomActions: Story = {
|
||||
render: () => <CustomActionsStory />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Shows how to replace the default submit button with a fully custom footer leveraging contextual form state.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
datasetName: 'Support FAQ',
|
||||
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
|
||||
},
|
||||
validators: {
|
||||
onChange: ({ value }) => value.datasetName?.length >= 3 ? undefined : 'Dataset name must contain at least 3 characters.',
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<form.AppField name="datasetName">
|
||||
{field => <field.TextField label="Dataset name" />}
|
||||
</form.AppField>
|
||||
<form.AppField name="datasetDescription">
|
||||
{field => <field.TextAreaField label="Description" />}
|
||||
</form.AppField>
|
||||
<form.AppForm>
|
||||
<form.Actions
|
||||
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={() => appForm.reset()} disabled={isSubmitting}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="tertiary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save draft
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</form>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import FullScreenModal from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/FullScreenModal',
|
||||
component: FullScreenModal,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Backdrop-blurred fullscreen modal. Supports close button, custom content, and optional overflow visibility.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FullScreenModal>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const ModalDemo = (props: React.ComponentProps<typeof FullScreenModal>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex h-[360px] items-center justify-center bg-background-default-subtle">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Launch full-screen modal
|
||||
</button>
|
||||
|
||||
<FullScreenModal
|
||||
{...props}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
closable
|
||||
>
|
||||
<div className="flex h-full flex-col bg-background-default-subtle">
|
||||
<div className="flex h-16 items-center justify-center border-b border-divider-subtle text-lg font-semibold text-text-primary">
|
||||
Full-screen experience
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-text-secondary">
|
||||
Place dashboards, flow builders, or immersive previews here.
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <ModalDemo {...args} />,
|
||||
args: {
|
||||
open: false,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import GridMask from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Layout/GridMask',
|
||||
component: GridMask,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Displays a soft grid overlay with gradient mask, useful for framing hero sections or marketing callouts.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
wrapperClassName: 'rounded-2xl p-10',
|
||||
canvasClassName: '',
|
||||
gradientClassName: '',
|
||||
children: (
|
||||
<div className="relative z-10 flex flex-col gap-3 text-left text-white">
|
||||
<span className="text-xs uppercase tracking-[0.16em] text-white/70">Grid Mask Demo</span>
|
||||
<span className="text-2xl font-semibold leading-tight">Beautiful backgrounds for feature highlights</span>
|
||||
<p className="max-w-md text-sm text-white/80">
|
||||
Place any content inside the mask. On dark backgrounds the grid and soft gradient add depth without distracting from the main message.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof GridMask>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const CustomBackground: Story = {
|
||||
args: {
|
||||
wrapperClassName: 'rounded-3xl p-10 bg-[#0A0A1A]',
|
||||
gradientClassName: 'bg-gradient-to-r from-[#0A0A1A]/90 via-[#101030]/60 to-[#05050A]/90',
|
||||
children: (
|
||||
<div className="flex flex-col gap-2 text-white">
|
||||
<span className="text-sm font-medium text-white/80">Custom gradient</span>
|
||||
<span className="text-3xl font-semibold leading-tight">Use your own colors</span>
|
||||
<p className="max-w-md text-sm text-white/70">
|
||||
Override gradient and canvas classes to match brand palettes while keeping the grid texture.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import ImageGallery from '.'
|
||||
|
||||
const IMAGE_SOURCES = [
|
||||
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23E0EAFF\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%23455675\'>Dataset</text></svg>',
|
||||
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23FEF7C3\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%237A5B00\'>Playground</text></svg>',
|
||||
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23D5F5F6\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%23045C63\'>Workflow</text></svg>',
|
||||
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23FCE7F6\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%238E2F63\'>Prompts</text></svg>',
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/ImageGallery',
|
||||
component: ImageGallery,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Responsive thumbnail grid with lightbox preview for larger imagery.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<ImageGallery srcs={[
|
||||
'data:image/svg+xml;utf8,<svg ... fill=%23E0EAFF ...>',
|
||||
'data:image/svg+xml;utf8,<svg ... fill=%23FEF7C3 ...>',
|
||||
]} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
srcs: IMAGE_SOURCES,
|
||||
},
|
||||
} satisfies Meta<typeof ImageGallery>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import ImageList from './image-list'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
const SAMPLE_BASE64
|
||||
= 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII='
|
||||
|
||||
const createRemoteImage = (
|
||||
id: string,
|
||||
progress: number,
|
||||
url: string,
|
||||
): ImageFile => ({
|
||||
type: TransferMethod.remote_url,
|
||||
_id: id,
|
||||
fileId: `remote-${id}`,
|
||||
progress,
|
||||
url,
|
||||
})
|
||||
|
||||
const createLocalImage = (id: string, progress: number): ImageFile => ({
|
||||
type: TransferMethod.local_file,
|
||||
_id: id,
|
||||
fileId: `local-${id}`,
|
||||
progress,
|
||||
url: SAMPLE_BASE64,
|
||||
base64Url: SAMPLE_BASE64,
|
||||
})
|
||||
|
||||
const initialImages: ImageFile[] = [
|
||||
createLocalImage('local-initial', 100),
|
||||
createRemoteImage(
|
||||
'remote-loading',
|
||||
40,
|
||||
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=300&q=80',
|
||||
),
|
||||
{
|
||||
...createRemoteImage(
|
||||
'remote-error',
|
||||
-1,
|
||||
'https://example.com/not-an-image.jpg',
|
||||
),
|
||||
url: 'https://example.com/not-an-image.jpg',
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/ImageList',
|
||||
component: ImageList,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
list: { control: false },
|
||||
onRemove: { control: false },
|
||||
onReUpload: { control: false },
|
||||
onImageLinkLoadError: { control: false },
|
||||
onImageLinkLoadSuccess: { control: false },
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ImageList>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const ImageUploaderPlayground = ({ readonly }: Story['args']) => {
|
||||
const [images, setImages] = useState<ImageFile[]>(() => initialImages)
|
||||
|
||||
const activeImages = useMemo(() => images.filter(item => !item.deleted), [images])
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
setImages(prev => prev.map(item => (item._id === id ? { ...item, deleted: true } : item)))
|
||||
}
|
||||
|
||||
const handleReUpload = (id: string) => {
|
||||
setImages(prev => prev.map((item) => {
|
||||
if (item._id !== id)
|
||||
return item
|
||||
|
||||
return {
|
||||
...item,
|
||||
progress: 60,
|
||||
}
|
||||
}))
|
||||
|
||||
setTimeout(() => {
|
||||
setImages(prev => prev.map((item) => {
|
||||
if (item._id !== id)
|
||||
return item
|
||||
|
||||
return {
|
||||
...item,
|
||||
progress: 100,
|
||||
}
|
||||
}))
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const handleImageLinkLoadSuccess = (id: string) => {
|
||||
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: 100 } : item)))
|
||||
}
|
||||
|
||||
const handleImageLinkLoadError = (id: string) => {
|
||||
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: -1 } : item)))
|
||||
}
|
||||
|
||||
const handleUploadFromLink = (imageFile: ImageFile) => {
|
||||
setImages(prev => [
|
||||
...prev,
|
||||
{
|
||||
...imageFile,
|
||||
fileId: `remote-${imageFile._id}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleAddLocalImage = () => {
|
||||
const id = `local-${Date.now()}`
|
||||
setImages(prev => [
|
||||
...prev,
|
||||
createLocalImage(id, 100),
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[360px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-[0.18em] text-text-tertiary">Add images</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageLinkInput onUpload={handleUploadFromLink} disabled={readonly} />
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:text-text-tertiary"
|
||||
onClick={handleAddLocalImage}
|
||||
disabled={readonly}
|
||||
>
|
||||
Simulate local
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageList
|
||||
list={activeImages}
|
||||
readonly={readonly}
|
||||
onRemove={handleRemove}
|
||||
onReUpload={handleReUpload}
|
||||
onImageLinkLoadSuccess={handleImageLinkLoadSuccess}
|
||||
onImageLinkLoadError={handleImageLinkLoadError}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default p-2">
|
||||
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.1em] text-text-tertiary">
|
||||
Files state
|
||||
</span>
|
||||
<pre className="max-h-40 overflow-auto text-[11px] leading-relaxed text-text-tertiary">
|
||||
{JSON.stringify(activeImages, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <ImageUploaderPlayground {...args} />,
|
||||
args: {
|
||||
list: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadonlyList: Story = {
|
||||
render: args => <ImageUploaderPlayground {...args} />,
|
||||
args: {
|
||||
list: [],
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import InlineDeleteConfirm from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/InlineDeleteConfirm',
|
||||
component: InlineDeleteConfirm,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['delete', 'warning', 'info'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
title: 'Delete this item?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
onConfirm: fn(),
|
||||
onCancel: fn(),
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof InlineDeleteConfirm>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const InlineDeleteConfirmDemo = (args: Story['args']) => {
|
||||
const [visible, setVisible] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
Trigger inline confirm
|
||||
</button>
|
||||
{visible && (
|
||||
<InlineDeleteConfirm
|
||||
{...args}
|
||||
onConfirm={() => {
|
||||
console.log('✅ Confirm clicked')
|
||||
setVisible(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
console.log('❎ Cancel clicked')
|
||||
setVisible(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <InlineDeleteConfirmDemo {...args} />,
|
||||
}
|
||||
|
||||
export const WarningVariant: Story = {
|
||||
render: args => <InlineDeleteConfirmDemo {...args} />,
|
||||
args: {
|
||||
variant: 'warning',
|
||||
title: 'Archive conversation?',
|
||||
confirmText: 'Archive',
|
||||
cancelText: 'Keep',
|
||||
},
|
||||
}
|
||||
|
||||
export const InfoVariant: Story = {
|
||||
render: args => <InlineDeleteConfirmDemo {...args} />,
|
||||
args: {
|
||||
variant: 'info',
|
||||
title: 'Remove collaborator?',
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Keep',
|
||||
},
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import { InputNumber } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/InputNumber',
|
||||
title: 'Base/Data Entry/InputNumber',
|
||||
component: InputNumber,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import Input from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/Input',
|
||||
title: 'Base/Data Entry/Input',
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import LinkedAppsPanel from '.'
|
||||
import type { RelatedApp } from '@/models/datasets'
|
||||
|
||||
const mockRelatedApps: RelatedApp[] = [
|
||||
{
|
||||
id: 'app-cx',
|
||||
name: 'Customer Support Assistant',
|
||||
mode: 'chat',
|
||||
icon_type: 'emoji',
|
||||
icon: '\u{1F4AC}',
|
||||
icon_background: '#EEF2FF',
|
||||
icon_url: '',
|
||||
},
|
||||
{
|
||||
id: 'app-ops',
|
||||
name: 'Ops Workflow Orchestrator',
|
||||
mode: 'workflow',
|
||||
icon_type: 'emoji',
|
||||
icon: '\u{1F6E0}\u{FE0F}',
|
||||
icon_background: '#ECFDF3',
|
||||
icon_url: '',
|
||||
},
|
||||
{
|
||||
id: 'app-research',
|
||||
name: 'Research Synthesizer',
|
||||
mode: 'advanced-chat',
|
||||
icon_type: 'emoji',
|
||||
icon: '\u{1F9E0}',
|
||||
icon_background: '#FDF2FA',
|
||||
icon_url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/LinkedAppsPanel',
|
||||
component: LinkedAppsPanel,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Shows a curated list of related applications, pairing each app icon with quick navigation links.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
relatedApps: mockRelatedApps,
|
||||
isMobile: false,
|
||||
},
|
||||
argTypes: {
|
||||
isMobile: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof LinkedAppsPanel>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Desktop: Story = {}
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {
|
||||
isMobile: true,
|
||||
},
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: 'mobile2',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import ListEmpty from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/ListEmpty',
|
||||
component: ListEmpty,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Large empty state card used in panels and drawers to hint at the next action for the user.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
title: 'No items yet',
|
||||
description: (
|
||||
<p className="text-xs leading-5 text-text-tertiary">
|
||||
Add your first entry to see it appear here. Empty states help users discover what happens next.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
argTypes: {
|
||||
description: { control: false },
|
||||
icon: { control: false },
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ListEmpty>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: {
|
||||
title: 'Connect a data source',
|
||||
description: (
|
||||
<p className="text-xs leading-5 text-text-secondary">
|
||||
Choose a database, knowledge base, or upload documents to get started with retrieval.
|
||||
</p>
|
||||
),
|
||||
icon: (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 via-primary-200 to-primary-300 text-primary-700 shadow-sm">
|
||||
{'\u{26A1}\u{FE0F}'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Loading from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Loading',
|
||||
component: Loading,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Spinner used while fetching data (`area`) or bootstrapping the full application shell (`app`).',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'radio',
|
||||
options: ['area', 'app'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
type: 'area',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Loading>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const LoadingPreview = ({ type }: { type: 'area' | 'app' }) => {
|
||||
const containerHeight = type === 'app' ? 'h-48' : 'h-20'
|
||||
const title = type === 'app' ? 'App loading state' : 'Inline loading state'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-text-tertiary">{title}</span>
|
||||
<div
|
||||
className={`flex w-64 items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle ${containerHeight}`}
|
||||
>
|
||||
<Loading type={type} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AreaSpinner: Story = {
|
||||
render: () => <LoadingPreview type="area" />,
|
||||
}
|
||||
|
||||
export const AppSpinner: Story = {
|
||||
render: () => <LoadingPreview type="app" />,
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import type { ReactNode } from 'react'
|
||||
import DifyLogo from './dify-logo'
|
||||
import LogoSite from './logo-site'
|
||||
import LogoEmbeddedChatHeader from './logo-embedded-chat-header'
|
||||
import LogoEmbeddedChatAvatar from './logo-embedded-chat-avatar'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/Logo',
|
||||
component: DifyLogo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Brand assets rendered in different contexts. DifyLogo adapts to the active theme while other variants target specific surfaces.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
size: 'medium',
|
||||
style: 'default',
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'radio',
|
||||
options: ['large', 'medium', 'small'],
|
||||
},
|
||||
style: {
|
||||
control: 'radio',
|
||||
options: ['default', 'monochromeWhite'],
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof DifyLogo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const ThemePreview = ({ theme, children }: { theme: 'light' | 'dark'; children: ReactNode }) => {
|
||||
return (
|
||||
<ThemeProvider attribute="data-theme" forcedTheme={theme} enableSystem={false}>
|
||||
<div
|
||||
className={'min-w-[320px] rounded-2xl border border-divider-subtle bg-background-default-subtle p-6 shadow-sm'}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: ({ size, style }) => {
|
||||
return (
|
||||
<ThemePreview theme="dark">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Primary logo</span>
|
||||
<div className="flex items-center justify-between rounded-xl border border-divider-subtle bg-background-default p-4">
|
||||
<DifyLogo size={size} style={style} />
|
||||
<code className="text-[11px] text-text-tertiary">{`size="${size}" | style="${style}"`}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-divider-subtle bg-background-default p-4">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.1em] text-text-tertiary">Site favicon</span>
|
||||
<LogoSite />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-divider-subtle bg-background-default p-4">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.1em] text-text-tertiary">Embedded header</span>
|
||||
<LogoEmbeddedChatHeader />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-divider-subtle bg-background-default p-4 sm:col-span-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.1em] text-text-tertiary">Embedded avatar</span>
|
||||
<LogoEmbeddedChatAvatar className="border-divider-strong rounded-2xl border" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemePreview>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import CodeBlock from './code-block'
|
||||
|
||||
const SAMPLE_CODE = `const greet = (name: string) => {
|
||||
return \`Hello, \${name}\`
|
||||
}
|
||||
|
||||
console.log(greet('Dify'))`
|
||||
|
||||
const CodeBlockDemo = ({
|
||||
language = 'typescript',
|
||||
}: {
|
||||
language?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Code block</div>
|
||||
<CodeBlock
|
||||
className={`language-${language}`}
|
||||
>
|
||||
{SAMPLE_CODE}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/CodeBlock',
|
||||
component: CodeBlockDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Syntax highlighted code block with copy button and SVG toggle support.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
language: {
|
||||
control: 'radio',
|
||||
options: ['typescript', 'json', 'mermaid'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
language: 'typescript',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof CodeBlockDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const Mermaid: Story = {
|
||||
args: {
|
||||
language: 'mermaid',
|
||||
},
|
||||
render: ({ language }) => (
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<CodeBlock className={`language-${language}`}>
|
||||
{`graph TD
|
||||
Start --> Decision{User message?}
|
||||
Decision -->|Tool| ToolCall[Call web search]
|
||||
Decision -->|Respond| Answer[Compose draft]
|
||||
`}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import ThinkBlock from './think-block'
|
||||
import { ChatContextProvider } from '@/app/components/base/chat/chat/context'
|
||||
|
||||
const THOUGHT_TEXT = `
|
||||
Gather docs from knowledge base.
|
||||
Score snippets against query.
|
||||
[ENDTHINKFLAG]
|
||||
`
|
||||
|
||||
const ThinkBlockDemo = ({
|
||||
responding = false,
|
||||
}: {
|
||||
responding?: boolean
|
||||
}) => {
|
||||
const [isResponding, setIsResponding] = useState(responding)
|
||||
|
||||
return (
|
||||
<ChatContextProvider
|
||||
config={undefined}
|
||||
isResponding={isResponding}
|
||||
chatList={[]}
|
||||
showPromptLog={false}
|
||||
questionIcon={undefined}
|
||||
answerIcon={undefined}
|
||||
onSend={undefined}
|
||||
onRegenerate={undefined}
|
||||
onAnnotationEdited={undefined}
|
||||
onAnnotationAdded={undefined}
|
||||
onAnnotationRemoved={undefined}
|
||||
onFeedback={undefined}
|
||||
>
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Think block</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setIsResponding(prev => !prev)}
|
||||
>
|
||||
{isResponding ? 'Mark complete' : 'Simulate thinking'}
|
||||
</button>
|
||||
</div>
|
||||
<ThinkBlock data-think>
|
||||
<pre className="whitespace-pre-wrap text-sm text-text-secondary">
|
||||
{THOUGHT_TEXT}
|
||||
</pre>
|
||||
</ThinkBlock>
|
||||
</div>
|
||||
</ChatContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/ThinkBlock',
|
||||
component: ThinkBlockDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Expandable chain-of-thought block used in chat responses. Toggles between “thinking” and completed states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
responding: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
responding: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ThinkBlockDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { Markdown } from '.'
|
||||
|
||||
const SAMPLE_MD = `
|
||||
# Product Update
|
||||
|
||||
Our agent now supports **tool-runs** with structured outputs.
|
||||
|
||||
## Highlights
|
||||
- Faster reasoning with \\(O(n \\log n)\\) planning.
|
||||
- Inline chain-of-thought:
|
||||
|
||||
<details data-think>
|
||||
<summary>Thinking aloud</summary>
|
||||
|
||||
Check cached metrics first.
|
||||
If missing, fetch raw warehouse data.
|
||||
[ENDTHINKFLAG]
|
||||
|
||||
</details>
|
||||
|
||||
## Mermaid Diagram
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
Start[User Message] --> Parse{Detect Intent?}
|
||||
Parse -->|Tool| ToolCall[Call search tool]
|
||||
Parse -->|Answer| Respond[Stream response]
|
||||
ToolCall --> Respond
|
||||
\`\`\`
|
||||
|
||||
## Code Example
|
||||
\`\`\`typescript
|
||||
const reply = await client.chat({
|
||||
message: 'Summarise weekly metrics.',
|
||||
tags: ['analytics'],
|
||||
})
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
const MarkdownDemo = ({
|
||||
compact = false,
|
||||
}: {
|
||||
compact?: boolean
|
||||
}) => {
|
||||
const [content] = useState(SAMPLE_MD.trim())
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Markdown renderer</div>
|
||||
<Markdown
|
||||
content={content}
|
||||
className={compact ? '!text-sm leading-relaxed' : ''}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Markdown',
|
||||
component: MarkdownDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Markdown wrapper with GitHub-flavored markdown, Mermaid diagrams, math, and custom blocks (details, audio, etc.).',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
compact: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
compact: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof MarkdownDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Flowchart from '.'
|
||||
|
||||
const SAMPLE = `
|
||||
flowchart LR
|
||||
A[User Message] --> B{Agent decides}
|
||||
B -->|Needs tool| C[Search Tool]
|
||||
C --> D[Combine result]
|
||||
B -->|Direct answer| D
|
||||
D --> E[Send response]
|
||||
`
|
||||
|
||||
const MermaidDemo = ({
|
||||
theme = 'light',
|
||||
}: {
|
||||
theme?: 'light' | 'dark'
|
||||
}) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(theme)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Mermaid diagram</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setCurrentTheme(prev => (prev === 'light' ? 'dark' : 'light'))}
|
||||
>
|
||||
Toggle theme
|
||||
</button>
|
||||
</div>
|
||||
<Flowchart PrimitiveCode={SAMPLE.trim()} theme={currentTheme} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Mermaid',
|
||||
component: MermaidDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Mermaid renderer with custom theme toggle and caching. Useful for visualizing agent flows.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
theme: {
|
||||
control: 'inline-radio',
|
||||
options: ['light', 'dark'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
theme: 'light',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof MermaidDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect } from 'react'
|
||||
import MessageLogModal from '.'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import type { WorkflowRunDetailResponse } from '@/models/log'
|
||||
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
const SAMPLE_APP_DETAIL = {
|
||||
id: 'app-demo-1',
|
||||
name: 'Support Assistant',
|
||||
mode: 'chat',
|
||||
} as any
|
||||
|
||||
const mockRunDetail: WorkflowRunDetailResponse = {
|
||||
id: 'run-demo-1',
|
||||
version: 'v1.0.0',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputs: JSON.stringify({ question: 'How do I reset my password?' }, null, 2),
|
||||
inputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
outputs: JSON.stringify({ answer: 'Follow the reset link we just emailed you.' }, null, 2),
|
||||
outputs_truncated: false,
|
||||
total_steps: 3,
|
||||
created_by_role: 'account',
|
||||
created_by_account: {
|
||||
id: 'account-1',
|
||||
name: 'Demo Admin',
|
||||
email: 'demo@example.com',
|
||||
},
|
||||
created_at: 1700000000,
|
||||
finished_at: 1700000006,
|
||||
elapsed_time: 5.2,
|
||||
total_tokens: 864,
|
||||
}
|
||||
|
||||
const buildNode = (override: Partial<NodeTracing>): NodeTracing => ({
|
||||
id: 'node-start',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'node-start',
|
||||
node_type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
metadata: {
|
||||
iterator_length: 1,
|
||||
iterator_index: 0,
|
||||
loop_length: 1,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 1700000000,
|
||||
created_by: {
|
||||
id: 'account-1',
|
||||
name: 'Demo Admin',
|
||||
email: 'demo@example.com',
|
||||
},
|
||||
finished_at: 1700000001,
|
||||
elapsed_time: 1.1,
|
||||
extras: {},
|
||||
...override,
|
||||
})
|
||||
|
||||
const mockTracingList: NodeTracingListResponse = {
|
||||
data: [
|
||||
buildNode({}),
|
||||
buildNode({
|
||||
id: 'node-answer',
|
||||
node_id: 'node-answer',
|
||||
node_type: BlockEnum.Answer,
|
||||
title: 'Answer',
|
||||
inputs: { prompt: 'How do I reset my password?' },
|
||||
outputs: { output: 'Follow the reset link we just emailed you.' },
|
||||
finished_at: 1700000005,
|
||||
elapsed_time: 2.6,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const mockCurrentLogItem: IChatItem = {
|
||||
id: 'message-1',
|
||||
content: 'Follow the reset link we just emailed you.',
|
||||
isAnswer: true,
|
||||
workflow_run_id: 'run-demo-1',
|
||||
}
|
||||
|
||||
const useMessageLogMocks = () => {
|
||||
useEffect(() => {
|
||||
const store = useStore.getState()
|
||||
store.setAppDetail(SAMPLE_APP_DETAIL)
|
||||
|
||||
const originalFetch = globalThis.fetch?.bind(globalThis) ?? null
|
||||
|
||||
const handle = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
|
||||
if (url.includes('/workflow-runs/run-demo-1/') && url.endsWith('/node-executions')) {
|
||||
return new Response(
|
||||
JSON.stringify(mockTracingList),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
|
||||
)
|
||||
}
|
||||
|
||||
if (url.endsWith('/workflow-runs/run-demo-1')) {
|
||||
return new Response(
|
||||
JSON.stringify(mockRunDetail),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
|
||||
)
|
||||
}
|
||||
|
||||
if (originalFetch)
|
||||
return originalFetch(input, init)
|
||||
|
||||
throw new Error(`Unmocked fetch call for ${url}`)
|
||||
}
|
||||
|
||||
globalThis.fetch = handle as typeof globalThis.fetch
|
||||
|
||||
return () => {
|
||||
globalThis.fetch = originalFetch || globalThis.fetch
|
||||
useStore.getState().setAppDetail(undefined)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
type MessageLogModalProps = React.ComponentProps<typeof MessageLogModal>
|
||||
|
||||
const MessageLogPreview = (props: MessageLogModalProps) => {
|
||||
useMessageLogMocks()
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
|
||||
<MessageLogModal
|
||||
{...props}
|
||||
currentLogItem={mockCurrentLogItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/MessageLogModal',
|
||||
component: MessageLogPreview,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Workflow run inspector presented alongside chat transcripts. This Storybook mock provides canned run details and tracing metadata.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
defaultTab: 'DETAIL',
|
||||
width: 960,
|
||||
fixedWidth: true,
|
||||
onCancel: () => {
|
||||
console.log('Modal closed')
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof MessageLogPreview>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const FixedPanel: Story = {}
|
||||
|
||||
export const FloatingPanel: Story = {
|
||||
args: {
|
||||
fixedWidth: false,
|
||||
},
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
|
|||
import ModalLikeWrap from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Dialog/ModalLikeWrap',
|
||||
title: 'Base/Feedback/ModalLikeWrap',
|
||||
component: ModalLikeWrap,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
|
|||
import Modal from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Dialog/Modal',
|
||||
title: 'Base/Feedback/Modal',
|
||||
component: Modal,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
|
|||
import Modal from './modal'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Dialog/RichModal',
|
||||
title: 'Base/Feedback/RichModal',
|
||||
component: Modal,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps<typeof AudioBtn>) => {
|
|||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Button/NewAudioButton',
|
||||
title: 'Base/General/NewAudioButton',
|
||||
component: AudioBtn,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import NotionConnector from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Other/NotionConnector',
|
||||
component: NotionConnector,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Call-to-action card inviting users to connect a Notion workspace. Shows the product icon, copy, and primary button.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onSetting: () => {
|
||||
console.log('Open Notion settings')
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof NotionConnector>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import NotionIcon from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/NotionIcon',
|
||||
component: NotionIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders workspace and page icons returned from Notion APIs, falling back to text initials or the default document glyph.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
type: 'workspace',
|
||||
name: 'Knowledge Base',
|
||||
src: 'https://cloud.dify.ai/logo/logo.svg',
|
||||
},
|
||||
} satisfies Meta<typeof NotionIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const WorkspaceIcon: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<NotionIcon {...args} />
|
||||
<span className="text-sm text-text-secondary">Workspace icon pulled from a remote URL.</span>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<NotionIcon
|
||||
type="workspace"
|
||||
name="Knowledge Base"
|
||||
src="https://cloud.dify.ai/logo/logo.svg"
|
||||
/>`
|
||||
.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WorkspaceInitials: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<NotionIcon {...args} src={null} name="Operations" />
|
||||
<span className="text-sm text-text-secondary">Fallback initial rendered when no icon URL is available.</span>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<NotionIcon type="workspace" name="Operations" src={null} />`
|
||||
.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const PageEmoji: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<NotionIcon {...args} type="page" src={{ type: 'emoji', emoji: '🧠', url: '' }} />
|
||||
<span className="text-sm text-text-secondary">Page-level emoji icon returned by the API.</span>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<NotionIcon type="page" src={{ type: 'emoji', emoji: '🧠' }} />`
|
||||
.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const PageImage: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<NotionIcon
|
||||
{...args}
|
||||
type="page"
|
||||
src={{ type: 'url', url: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=80&q=60', emoji: '' }}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">Page icon resolved from an image URL.</span>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<NotionIcon
|
||||
type="page"
|
||||
src={{ type: 'url', url: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=80&q=60' }}
|
||||
/>`
|
||||
.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DefaultIcon: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
|
||||
<NotionIcon {...args} type="page" src={undefined} />
|
||||
<span className="text-sm text-text-secondary">When neither emoji nor URL is provided, the generic document icon is shown.</span>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<NotionIcon type="page" src={undefined} />`
|
||||
.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import { NotionPageSelector } from '.'
|
||||
import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
|
||||
const DATASET_ID = 'dataset-demo'
|
||||
const CREDENTIALS: DataSourceCredential[] = [
|
||||
{
|
||||
id: 'cred-1',
|
||||
name: 'Marketing Workspace',
|
||||
type: CredentialTypeEnum.OAUTH2,
|
||||
is_default: true,
|
||||
avatar_url: '',
|
||||
credential: {
|
||||
workspace_name: 'Marketing Workspace',
|
||||
workspace_icon: null,
|
||||
workspace_id: 'workspace-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cred-2',
|
||||
name: 'Product Workspace',
|
||||
type: CredentialTypeEnum.OAUTH2,
|
||||
is_default: false,
|
||||
avatar_url: '',
|
||||
credential: {
|
||||
workspace_name: 'Product Workspace',
|
||||
workspace_icon: null,
|
||||
workspace_id: 'workspace-2',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const marketingPages = {
|
||||
notion_info: [
|
||||
{
|
||||
workspace_name: 'Marketing Workspace',
|
||||
workspace_id: 'workspace-1',
|
||||
workspace_icon: null,
|
||||
pages: [
|
||||
{
|
||||
page_icon: { type: 'emoji', emoji: '\u{1F4CB}', url: null },
|
||||
page_id: 'briefs',
|
||||
page_name: 'Campaign Briefs',
|
||||
parent_id: 'root',
|
||||
type: 'page',
|
||||
is_bound: false,
|
||||
},
|
||||
{
|
||||
page_icon: { type: 'emoji', emoji: '\u{1F4DD}', url: null },
|
||||
page_id: 'notes',
|
||||
page_name: 'Meeting Notes',
|
||||
parent_id: 'root',
|
||||
type: 'page',
|
||||
is_bound: true,
|
||||
},
|
||||
{
|
||||
page_icon: { type: 'emoji', emoji: '\u{1F30D}', url: null },
|
||||
page_id: 'localizations',
|
||||
page_name: 'Localization Pipeline',
|
||||
parent_id: 'briefs',
|
||||
type: 'page',
|
||||
is_bound: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const productPages = {
|
||||
notion_info: [
|
||||
{
|
||||
workspace_name: 'Product Workspace',
|
||||
workspace_id: 'workspace-2',
|
||||
workspace_icon: null,
|
||||
pages: [
|
||||
{
|
||||
page_icon: { type: 'emoji', emoji: '\u{1F4A1}', url: null },
|
||||
page_id: 'ideas',
|
||||
page_name: 'Idea Backlog',
|
||||
parent_id: 'root',
|
||||
type: 'page',
|
||||
is_bound: false,
|
||||
},
|
||||
{
|
||||
page_icon: { type: 'emoji', emoji: '\u{1F9EA}', url: null },
|
||||
page_id: 'experiments',
|
||||
page_name: 'Experiments',
|
||||
parent_id: 'ideas',
|
||||
type: 'page',
|
||||
is_bound: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
type NotionApiResponse = typeof marketingPages
|
||||
const emptyNotionResponse: NotionApiResponse = { notion_info: [] }
|
||||
|
||||
const useMockNotionApi = () => {
|
||||
const responseMap = useMemo(() => ({
|
||||
[`${DATASET_ID}:cred-1`]: marketingPages,
|
||||
[`${DATASET_ID}:cred-2`]: productPages,
|
||||
}) satisfies Record<`${typeof DATASET_ID}:${typeof CREDENTIALS[number]['id']}`, NotionApiResponse>, [])
|
||||
|
||||
useEffect(() => {
|
||||
const originalFetch = globalThis.fetch?.bind(globalThis)
|
||||
|
||||
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
|
||||
if (url.includes('/notion/pre-import/pages')) {
|
||||
const parsed = new URL(url, globalThis.location.origin)
|
||||
const datasetId = parsed.searchParams.get('dataset_id') || ''
|
||||
const credentialId = parsed.searchParams.get('credential_id') || ''
|
||||
let payload: NotionApiResponse = emptyNotionResponse
|
||||
|
||||
if (datasetId === DATASET_ID) {
|
||||
const credential = CREDENTIALS.find(item => item.id === credentialId)
|
||||
if (credential) {
|
||||
const mapKey = `${DATASET_ID}:${credential.id}` as keyof typeof responseMap
|
||||
payload = responseMap[mapKey]
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(payload),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
|
||||
)
|
||||
}
|
||||
|
||||
if (originalFetch)
|
||||
return originalFetch(input, init)
|
||||
|
||||
throw new Error(`Unmocked fetch call for ${url}`)
|
||||
}
|
||||
|
||||
globalThis.fetch = handler as typeof globalThis.fetch
|
||||
|
||||
return () => {
|
||||
if (originalFetch)
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
}, [responseMap])
|
||||
}
|
||||
|
||||
const NotionSelectorPreview = () => {
|
||||
const [selectedPages, setSelectedPages] = useState<NotionPage[]>([])
|
||||
const [credentialId, setCredentialId] = useState<string>()
|
||||
|
||||
useMockNotionApi()
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<NotionPageSelector
|
||||
datasetId={DATASET_ID}
|
||||
credentialList={CREDENTIALS}
|
||||
value={selectedPages.map(page => page.page_id)}
|
||||
onSelect={setSelectedPages}
|
||||
onSelectCredential={setCredentialId}
|
||||
canPreview
|
||||
/>
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle p-4 text-xs text-text-secondary">
|
||||
<div className="mb-2 font-semibold uppercase tracking-[0.18em] text-text-tertiary">
|
||||
Debug state
|
||||
</div>
|
||||
<p className="mb-1">Active credential: <span className="font-mono">{credentialId || 'None'}</span></p>
|
||||
<pre className="max-h-40 overflow-auto rounded-lg bg-background-default p-3 font-mono text-[11px] leading-relaxed text-text-tertiary">
|
||||
{JSON.stringify(selectedPages, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Other/NotionPageSelector',
|
||||
component: NotionSelectorPreview,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Credential-aware selector that fetches Notion pages and lets users choose which ones to sync.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof NotionSelectorPreview>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Pagination from '.'
|
||||
|
||||
const TOTAL_ITEMS = 120
|
||||
|
||||
const PaginationDemo = ({
|
||||
initialPage = 0,
|
||||
initialLimit = 10,
|
||||
}: {
|
||||
initialPage?: number
|
||||
initialLimit?: number
|
||||
}) => {
|
||||
const [current, setCurrent] = useState(initialPage)
|
||||
const [limit, setLimit] = useState(initialLimit)
|
||||
|
||||
const pageSummary = useMemo(() => {
|
||||
const start = current * limit + 1
|
||||
const end = Math.min((current + 1) * limit, TOTAL_ITEMS)
|
||||
return `${start}-${end} of ${TOTAL_ITEMS}`
|
||||
}, [current, limit])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Log pagination</span>
|
||||
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 font-medium text-text-secondary">
|
||||
{pageSummary}
|
||||
</span>
|
||||
</div>
|
||||
<Pagination
|
||||
current={current}
|
||||
total={TOTAL_ITEMS}
|
||||
limit={limit}
|
||||
onChange={setCurrent}
|
||||
onLimitChange={(nextLimit) => {
|
||||
setCurrent(0)
|
||||
setLimit(nextLimit)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/Pagination',
|
||||
component: PaginationDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialPage: 0,
|
||||
initialLimit: 10,
|
||||
},
|
||||
argTypes: {
|
||||
initialPage: {
|
||||
control: { type: 'number', min: 0, max: 9, step: 1 },
|
||||
},
|
||||
initialLimit: {
|
||||
control: { type: 'radio' },
|
||||
options: [10, 25, 50],
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PaginationDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const StartAtMiddle: Story = {
|
||||
args: {
|
||||
initialPage: 4,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import ParamItem from '.'
|
||||
|
||||
type ParamConfig = {
|
||||
id: string
|
||||
name: string
|
||||
tip: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
allowToggle?: boolean
|
||||
}
|
||||
|
||||
const PARAMS: ParamConfig[] = [
|
||||
{
|
||||
id: 'temperature',
|
||||
name: 'Temperature',
|
||||
tip: 'Controls randomness. Lower values make the model more deterministic, higher values encourage creativity.',
|
||||
value: 0.7,
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
allowToggle: true,
|
||||
},
|
||||
{
|
||||
id: 'top_p',
|
||||
name: 'Top P',
|
||||
tip: 'Nucleus sampling keeps only the most probable tokens whose cumulative probability exceeds this threshold.',
|
||||
value: 0.9,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
id: 'frequency_penalty',
|
||||
name: 'Frequency Penalty',
|
||||
tip: 'Discourages repeating tokens. Increase to reduce repetition.',
|
||||
value: 0.2,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
]
|
||||
|
||||
const ParamItemPlayground = () => {
|
||||
const [state, setState] = useState<Record<string, { value: number; enabled: boolean }>>(() => {
|
||||
return PARAMS.reduce((acc, item) => {
|
||||
acc[item.id] = { value: item.value, enabled: true }
|
||||
return acc
|
||||
}, {} as Record<string, { value: number; enabled: boolean }>)
|
||||
})
|
||||
|
||||
const handleChange = (id: string, value: number) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
value: Number.parseFloat(value.toFixed(3)),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleToggle = (id: string, enabled: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
enabled,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-2xl flex-col gap-5 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Generation parameters</span>
|
||||
<code className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
|
||||
{JSON.stringify(state, null, 0)}
|
||||
</code>
|
||||
</div>
|
||||
{PARAMS.map(param => (
|
||||
<ParamItem
|
||||
key={param.id}
|
||||
className="rounded-xl border border-transparent px-3 py-2 hover:border-divider-subtle hover:bg-background-default-subtle"
|
||||
id={param.id}
|
||||
name={param.name}
|
||||
tip={param.tip}
|
||||
value={state[param.id].value}
|
||||
enable={state[param.id].enabled}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
hasSwitch={param.allowToggle}
|
||||
onChange={handleChange}
|
||||
onSwitchChange={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/ParamItem',
|
||||
component: ParamItemPlayground,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Slider + numeric input pairing used for model parameter tuning. Supports optional enable toggles per parameter.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ParamItemPlayground>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import CustomPopover from '.'
|
||||
|
||||
type PopoverContentProps = {
|
||||
open?: boolean
|
||||
onClose?: () => void
|
||||
onClick?: () => void
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const PopoverContent = ({ title, description, onClose }: PopoverContentProps) => {
|
||||
return (
|
||||
<div className="flex min-w-[220px] flex-col gap-2 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-sm leading-5 text-text-secondary">{description}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="self-start rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover"
|
||||
onClick={onClose}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Template = ({
|
||||
trigger = 'hover',
|
||||
position = 'bottom',
|
||||
manualClose,
|
||||
disabled,
|
||||
}: {
|
||||
trigger?: 'click' | 'hover'
|
||||
position?: 'bottom' | 'bl' | 'br'
|
||||
manualClose?: boolean
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
const [hoverHint] = useState(
|
||||
trigger === 'hover'
|
||||
? 'Hover over the badge to reveal quick tips.'
|
||||
: 'Click the badge to open the contextual menu.',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<p className="text-sm text-text-secondary">{hoverHint}</p>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<CustomPopover
|
||||
trigger={trigger}
|
||||
position={position}
|
||||
manualClose={manualClose}
|
||||
disabled={disabled}
|
||||
btnElement={<span className="text-xs font-medium text-text-secondary">Popover trigger</span>}
|
||||
htmlContent={
|
||||
<PopoverContent
|
||||
title={trigger === 'hover' ? 'Quick help' : 'More actions'}
|
||||
description={trigger === 'hover'
|
||||
? 'Use hover-triggered popovers for light contextual hints and inline docs.'
|
||||
: 'Click-triggered popovers are ideal for menus that require user decisions.'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Popover',
|
||||
component: Template,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Headless UI popover wrapper supporting hover and click triggers. These examples highlight alignment controls and manual closing.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
trigger: {
|
||||
control: 'radio',
|
||||
options: ['hover', 'click'],
|
||||
},
|
||||
position: {
|
||||
control: 'radio',
|
||||
options: ['bottom', 'bl', 'br'],
|
||||
},
|
||||
manualClose: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
trigger: 'hover',
|
||||
position: 'bottom',
|
||||
manualClose: false,
|
||||
disabled: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Template>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const HoverPopover: Story = {}
|
||||
|
||||
export const ClickPopover: Story = {
|
||||
args: {
|
||||
trigger: 'click',
|
||||
position: 'br',
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledState: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '.'
|
||||
|
||||
const TooltipCard = ({ title, description }: { title: string; description: string }) => (
|
||||
<div className="w-[220px] rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-sm text-text-secondary shadow-lg">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-text-tertiary">
|
||||
{title}
|
||||
</div>
|
||||
<p className="leading-5">{description}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const PortalDemo = ({
|
||||
placement = 'bottom',
|
||||
triggerPopupSameWidth = false,
|
||||
}: {
|
||||
placement?: Parameters<typeof PortalToFollowElem>[0]['placement']
|
||||
triggerPopupSameWidth?: boolean
|
||||
}) => {
|
||||
const [controlledOpen, setControlledOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<PortalToFollowElem placement={placement} triggerPopupSameWidth={triggerPopupSameWidth}>
|
||||
<PortalToFollowElemTrigger className="rounded-md border border-divider-subtle bg-background-default px-3 py-2 text-sm text-text-secondary">
|
||||
Hover me
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-40">
|
||||
<TooltipCard
|
||||
title="Auto follow"
|
||||
description="The floating element repositions itself when the trigger moves, using Floating UI under the hood."
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
triggerPopupSameWidth
|
||||
open={controlledOpen}
|
||||
onOpenChange={setControlledOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default-subtle px-3 py-2 text-sm font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setControlledOpen(prev => !prev)}
|
||||
>
|
||||
Controlled toggle
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-40">
|
||||
<TooltipCard
|
||||
title="Controlled"
|
||||
description="This panel uses the controlled API via onOpenChange/open props, and matches the trigger width."
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/PortalToFollowElem',
|
||||
component: PortalDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Floating UI based portal that tracks trigger positioning. Demonstrates both hover-driven and controlled usage.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: 'select',
|
||||
options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end'],
|
||||
},
|
||||
triggerPopupSameWidth: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
placement: 'bottom',
|
||||
triggerPopupSameWidth: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PortalDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const SameWidthPanel: Story = {
|
||||
args: {
|
||||
triggerPopupSameWidth: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import PremiumBadge from '.'
|
||||
|
||||
const colors: Array<NonNullable<React.ComponentProps<typeof PremiumBadge>['color']>> = ['blue', 'indigo', 'gray', 'orange']
|
||||
|
||||
const PremiumBadgeGallery = ({
|
||||
size = 'm',
|
||||
allowHover = false,
|
||||
}: {
|
||||
size?: 's' | 'm'
|
||||
allowHover?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Brand badge variants</p>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{colors.map(color => (
|
||||
<div key={color} className="flex flex-col items-center gap-2 rounded-xl border border-transparent px-2 py-4 hover:border-divider-subtle hover:bg-background-default-subtle">
|
||||
<PremiumBadge color={color} size={size} allowHover={allowHover}>
|
||||
<span className="px-2 text-xs font-semibold uppercase tracking-[0.14em]">Premium</span>
|
||||
</PremiumBadge>
|
||||
<span className="text-[11px] uppercase tracking-[0.16em] text-text-tertiary">{color}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/PremiumBadge',
|
||||
component: PremiumBadgeGallery,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Gradient badge used for premium features and upsell prompts. Hover animations can be toggled per instance.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'radio',
|
||||
options: ['s', 'm'],
|
||||
},
|
||||
allowHover: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
size: 'm',
|
||||
allowHover: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PremiumBadgeGallery>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const HoverEnabled: Story = {
|
||||
args: {
|
||||
allowHover: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import ProgressCircle from './progress-circle'
|
||||
|
||||
const ProgressCircleDemo = ({
|
||||
initialPercentage = 42,
|
||||
size = 24,
|
||||
}: {
|
||||
initialPercentage?: number
|
||||
size?: number
|
||||
}) => {
|
||||
const [percentage, setPercentage] = useState(initialPercentage)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Upload progress</span>
|
||||
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 text-[11px] text-text-secondary">
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<ProgressCircle percentage={percentage} size={size} className="shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={percentage}
|
||||
onChange={event => setPercentage(Number.parseInt(event.target.value, 10))}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-text-tertiary">
|
||||
<label className="flex items-center gap-1">
|
||||
Size
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
max={48}
|
||||
value={size}
|
||||
disabled
|
||||
className="h-7 w-16 rounded-md border border-divider-subtle bg-background-default px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-[11px] leading-relaxed text-text-tertiary">
|
||||
ProgressCircle renders a deterministic SVG slice. Advance the slider to preview how the arc grows for upload indicators.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/ProgressCircle',
|
||||
component: ProgressCircleDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compact radial progress indicator wired to upload flows. The story provides a slider to scrub through percentages.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialPercentage: {
|
||||
control: { type: 'range', min: 0, max: 100, step: 1 },
|
||||
},
|
||||
size: {
|
||||
control: { type: 'number', min: 12, max: 48, step: 2 },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialPercentage: 42,
|
||||
size: 24,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ProgressCircleDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const NearComplete: Story = {
|
||||
args: {
|
||||
initialPercentage: 92,
|
||||
},
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, cla
|
|||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/PromptEditor',
|
||||
title: 'Base/Data Entry/PromptEditor',
|
||||
component: PromptEditorMock,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect } from 'react'
|
||||
import PromptLogModal from '.'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
|
||||
type PromptLogModalProps = React.ComponentProps<typeof PromptLogModal>
|
||||
|
||||
const mockLogItem: IChatItem = {
|
||||
id: 'message-1',
|
||||
isAnswer: true,
|
||||
content: 'Summarize our meeting notes about launch blockers.',
|
||||
log: [
|
||||
{
|
||||
role: 'system',
|
||||
text: 'You are an assistant that extracts key launch blockers from the dialogue.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
text: 'Team discussed QA, marketing assets, and infra readiness. Highlight risks.',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
text: 'Blocking items:\n1. QA needs staging data by Friday.\n2. Marketing awaiting final visuals.\n3. Infra rollout still missing approval.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const usePromptLogMocks = () => {
|
||||
useEffect(() => {
|
||||
useStore.getState().setCurrentLogItem(mockLogItem)
|
||||
return () => {
|
||||
useStore.getState().setCurrentLogItem(undefined)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
const PromptLogPreview = (props: PromptLogModalProps) => {
|
||||
usePromptLogMocks()
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
|
||||
<PromptLogModal
|
||||
{...props}
|
||||
currentLogItem={mockLogItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/PromptLogModal',
|
||||
component: PromptLogPreview,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Shows the prompt and message transcript used for a chat completion, with copy-to-clipboard support for single prompts.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
width: 960,
|
||||
onCancel: () => {
|
||||
console.log('Prompt log closed')
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PromptLogPreview>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import ShareQRCode from '.'
|
||||
|
||||
const QRDemo = ({
|
||||
content = 'https://dify.ai',
|
||||
}: {
|
||||
content?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Share QR</p>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Generated URL:</span>
|
||||
<code className="rounded-md bg-background-default px-2 py-1 text-[11px]">{content}</code>
|
||||
</div>
|
||||
<ShareQRCode content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/QRCode',
|
||||
component: QRDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Toggleable QR code generator for sharing app URLs. Clicking the trigger reveals the code with a download CTA.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
content: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
content: 'https://dify.ai',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof QRDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const DemoLink: Story = {
|
||||
args: {
|
||||
content: 'https://dify.ai/docs',
|
||||
},
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine,
|
|||
import RadioCard from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/RadioCard',
|
||||
title: 'Base/Data Entry/RadioCard',
|
||||
component: RadioCard,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import Radio from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/Radio',
|
||||
title: 'Base/Data Entry/Radio',
|
||||
component: Radio,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import SearchInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/SearchInput',
|
||||
title: 'Base/Data Entry/SearchInput',
|
||||
component: SearchInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { RiLineChartLine, RiListCheck2, RiRobot2Line } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { SegmentedControl } from '.'
|
||||
|
||||
const SEGMENTS = [
|
||||
{ value: 'overview', text: 'Overview', Icon: RiLineChartLine },
|
||||
{ value: 'tasks', text: 'Tasks', Icon: RiListCheck2, count: 8 },
|
||||
{ value: 'agents', text: 'Agents', Icon: RiRobot2Line },
|
||||
]
|
||||
|
||||
const SegmentedControlDemo = ({
|
||||
initialValue = 'overview',
|
||||
size = 'regular',
|
||||
padding = 'with',
|
||||
activeState = 'default',
|
||||
}: {
|
||||
initialValue?: string
|
||||
size?: 'regular' | 'small' | 'large'
|
||||
padding?: 'none' | 'with'
|
||||
activeState?: 'default' | 'accent' | 'accentLight'
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Segmented control</span>
|
||||
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
|
||||
value="{value}"
|
||||
</code>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={SEGMENTS}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
size={size}
|
||||
padding={padding}
|
||||
activeState={activeState}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/SegmentedControl',
|
||||
component: SegmentedControlDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Multi-tab segmented control with optional icons and badge counts. Adjust sizing and accent states via controls.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialValue: {
|
||||
control: 'radio',
|
||||
options: SEGMENTS.map(segment => segment.value),
|
||||
},
|
||||
size: {
|
||||
control: 'inline-radio',
|
||||
options: ['small', 'regular', 'large'],
|
||||
},
|
||||
padding: {
|
||||
control: 'inline-radio',
|
||||
options: ['none', 'with'],
|
||||
},
|
||||
activeState: {
|
||||
control: 'inline-radio',
|
||||
options: ['default', 'accent', 'accentLight'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialValue: 'overview',
|
||||
size: 'regular',
|
||||
padding: 'with',
|
||||
activeState: 'default',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SegmentedControlDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const AccentState: Story = {
|
||||
args: {
|
||||
activeState: 'accent',
|
||||
},
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import Select, { PortalSelect, SimpleSelect } from '.'
|
|||
import type { Item } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/Select',
|
||||
title: 'Base/Data Entry/Select',
|
||||
component: SimpleSelect,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import SimplePieChart from '.'
|
||||
|
||||
const PieChartPlayground = ({
|
||||
initialPercentage = 65,
|
||||
fill = '#fdb022',
|
||||
stroke = '#f79009',
|
||||
}: {
|
||||
initialPercentage?: number
|
||||
fill?: string
|
||||
stroke?: string
|
||||
}) => {
|
||||
const [percentage, setPercentage] = useState(initialPercentage)
|
||||
|
||||
const label = useMemo(() => `${percentage}%`, [percentage])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Conversion snapshot</span>
|
||||
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 text-[11px] text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<SimplePieChart
|
||||
percentage={percentage}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
size={120}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<label className="flex items-center justify-between text-xs font-medium text-text-secondary">
|
||||
Target progress
|
||||
<span className="rounded bg-background-default px-2 py-1 text-[11px] text-text-tertiary">{label}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={percentage}
|
||||
onChange={event => setPercentage(Number.parseInt(event.target.value, 10))}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/SimplePieChart',
|
||||
component: PieChartPlayground,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Thin radial indicator built with ECharts. Use it for quick percentage snapshots inside cards.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialPercentage: {
|
||||
control: { type: 'range', min: 0, max: 100, step: 1 },
|
||||
},
|
||||
fill: { control: 'color' },
|
||||
stroke: { control: 'color' },
|
||||
},
|
||||
args: {
|
||||
initialPercentage: 65,
|
||||
fill: '#fdb022',
|
||||
stroke: '#f79009',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PieChartPlayground>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const BrandAccent: Story = {
|
||||
args: {
|
||||
fill: '#155EEF',
|
||||
stroke: '#0040C1',
|
||||
initialPercentage: 82,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import {
|
||||
SkeletonContainer,
|
||||
SkeletonPoint,
|
||||
SkeletonRectangle,
|
||||
SkeletonRow,
|
||||
} from '.'
|
||||
|
||||
const SkeletonDemo = () => {
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Loading skeletons</div>
|
||||
<div className="space-y-4 rounded-xl border border-divider-subtle bg-background-default-subtle p-4">
|
||||
<SkeletonContainer>
|
||||
<SkeletonRow>
|
||||
<SkeletonRectangle className="h-4 w-32 rounded-md" />
|
||||
<SkeletonPoint />
|
||||
<SkeletonRectangle className="h-4 w-20 rounded-md" />
|
||||
</SkeletonRow>
|
||||
<SkeletonRow>
|
||||
<SkeletonRectangle className="h-3 w-full" />
|
||||
</SkeletonRow>
|
||||
<SkeletonRow>
|
||||
<SkeletonRectangle className="h-3 w-5/6" />
|
||||
</SkeletonRow>
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-xl border border-divider-subtle bg-background-default-subtle p-4">
|
||||
<SkeletonRow className="items-start">
|
||||
<SkeletonRectangle className="mr-4 h-10 w-10 rounded-full" />
|
||||
<SkeletonContainer className="w-full">
|
||||
<SkeletonRectangle className="h-3 w-1/3" />
|
||||
<SkeletonRectangle className="h-3 w-full" />
|
||||
<SkeletonRectangle className="h-3 w-3/4" />
|
||||
</SkeletonContainer>
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Skeleton',
|
||||
component: SkeletonDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Composable skeleton primitives (container, row, rectangle, point) to sketch loading states for panels and lists.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SkeletonDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import Slider from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/Slider',
|
||||
title: 'Base/Data Entry/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Sort from '.'
|
||||
|
||||
const SORT_ITEMS = [
|
||||
{ value: 'created_at', name: 'Created time' },
|
||||
{ value: 'updated_at', name: 'Updated time' },
|
||||
{ value: 'latency', name: 'Latency' },
|
||||
]
|
||||
|
||||
const SortPlayground = () => {
|
||||
const [sortBy, setSortBy] = useState('-created_at')
|
||||
|
||||
const { order, value } = useMemo(() => {
|
||||
const isDesc = sortBy.startsWith('-')
|
||||
return {
|
||||
order: isDesc ? '-' : '',
|
||||
value: sortBy.replace('-', '') || 'created_at',
|
||||
}
|
||||
}, [sortBy])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Sort control</span>
|
||||
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
|
||||
sort_by="{sortBy}"
|
||||
</code>
|
||||
</div>
|
||||
<Sort
|
||||
order={order}
|
||||
value={value}
|
||||
items={SORT_ITEMS}
|
||||
onSelect={(next) => {
|
||||
setSortBy(next as string)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Sort',
|
||||
component: SortPlayground,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Sorting trigger used in log tables. Includes dropdown selection and quick toggle between ascending and descending.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SortPlayground>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Spinner from '.'
|
||||
|
||||
const SpinnerPlayground = ({
|
||||
loading = true,
|
||||
}: {
|
||||
loading?: boolean
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(loading)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-xs flex-col items-center gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Spinner</p>
|
||||
<Spinner loading={isLoading} className="text-primary-500" />
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setIsLoading(prev => !prev)}
|
||||
>
|
||||
{isLoading ? 'Stop' : 'Start'} loading
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Spinner',
|
||||
component: SpinnerPlayground,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Minimal spinner powered by Tailwind utilities. Toggle the state to inspect motion-reduced behaviour.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
loading: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
loading: true,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SpinnerPlayground>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import SVGRenderer from '.'
|
||||
|
||||
const SAMPLE_SVG = `
|
||||
<svg width="400" height="280" viewBox="0 0 400 280" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#D1E9FF"/>
|
||||
<stop offset="100%" stop-color="#FBE8FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="280" rx="24" fill="url(#bg)"/>
|
||||
<g font-family="sans-serif" fill="#1F2937" text-anchor="middle">
|
||||
<text x="200" y="120" font-size="32" font-weight="600">SVG Preview</text>
|
||||
<text x="200" y="160" font-size="16">Click to open high-resolution preview</text>
|
||||
</g>
|
||||
<circle cx="320" cy="70" r="28" fill="#E0F2FE" stroke="#2563EB" stroke-width="4"/>
|
||||
<circle cx="80" cy="200" r="18" fill="#FDE68A" stroke="#CA8A04" stroke-width="4"/>
|
||||
<rect x="120" y="190" width="160" height="48" rx="12" fill="#FFF" opacity="0.85"/>
|
||||
<text x="200" y="220" font-size="16" font-weight="500">Inline SVG asset</text>
|
||||
</svg>
|
||||
`.trim()
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/SVGRenderer',
|
||||
component: SVGRenderer,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Renders sanitized SVG markup with zoom-to-preview capability.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<SVGRenderer content={\`
|
||||
<svg width="400" height="280" ...>...</svg>
|
||||
\`} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
content: SAMPLE_SVG,
|
||||
},
|
||||
} satisfies Meta<typeof SVGRenderer>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import SVGBtn from '.'
|
||||
|
||||
const SvgToggleDemo = () => {
|
||||
const [isSVG, setIsSVG] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-xs flex-col items-center gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">SVG toggle</p>
|
||||
<SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />
|
||||
<span className="text-xs text-text-secondary">
|
||||
Mode: <code className="rounded bg-background-default px-2 py-1 text-[11px]">{isSVG ? 'SVG' : 'PNG'}</code>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/SVGBtn',
|
||||
component: SvgToggleDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Small toggle used in icon pickers to switch between SVG and bitmap assets.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SvgToggleDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import Switch from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/Switch',
|
||||
title: 'Base/Data Entry/Switch',
|
||||
component: Switch,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import TabHeader from '.'
|
||||
import type { ITabHeaderProps } from '.'
|
||||
|
||||
const items: ITabHeaderProps['items'] = [
|
||||
{ id: 'overview', name: 'Overview' },
|
||||
{ id: 'playground', name: 'Playground' },
|
||||
{ id: 'changelog', name: 'Changelog', extra: <span className="ml-1 rounded-full bg-primary-50 px-2 py-0.5 text-xs text-primary-600">New</span> },
|
||||
{ id: 'docs', name: 'Docs', isRight: true },
|
||||
{ id: 'settings', name: 'Settings', isRight: true, disabled: true },
|
||||
]
|
||||
|
||||
const TabHeaderDemo = ({
|
||||
initialTab = 'overview',
|
||||
}: {
|
||||
initialTab?: string
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Tabs</span>
|
||||
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
|
||||
active="{activeTab}"
|
||||
</code>
|
||||
</div>
|
||||
<TabHeader
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/TabHeader',
|
||||
component: TabHeaderDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialTab: {
|
||||
control: 'radio',
|
||||
options: items.map(item => item.id),
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialTab: 'overview',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TabHeaderDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { RiSparklingFill, RiTerminalBoxLine } from '@remixicon/react'
|
||||
import TabSliderNew from '.'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'visual', text: 'Visual builder', icon: <RiSparklingFill className="mr-2 h-4 w-4 text-primary-500" /> },
|
||||
{ value: 'code', text: 'Code', icon: <RiTerminalBoxLine className="mr-2 h-4 w-4 text-text-tertiary" /> },
|
||||
]
|
||||
|
||||
const TabSliderNewDemo = ({
|
||||
initialValue = 'visual',
|
||||
}: {
|
||||
initialValue?: string
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Pill tabs</div>
|
||||
<TabSliderNew value={value} options={OPTIONS} onChange={setValue} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/TabSliderNew',
|
||||
component: TabSliderNewDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Rounded pill tabs suited for switching between editors. Icons illustrate mixed text/icon options.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialValue: {
|
||||
control: 'radio',
|
||||
options: OPTIONS.map(option => option.value),
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialValue: 'visual',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TabSliderNewDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import TabSliderPlain from '.'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'analytics', text: 'Analytics' },
|
||||
{ value: 'activity', text: 'Recent activity' },
|
||||
{ value: 'alerts', text: 'Alerts' },
|
||||
]
|
||||
|
||||
const TabSliderPlainDemo = ({
|
||||
initialValue = 'analytics',
|
||||
}: {
|
||||
initialValue?: string
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-2xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Underline tabs</div>
|
||||
<TabSliderPlain
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
options={OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/TabSliderPlain',
|
||||
component: TabSliderPlainDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Underline-style navigation commonly used in dashboards. Toggle between three sections.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialValue: {
|
||||
control: 'radio',
|
||||
options: OPTIONS.map(option => option.value),
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialValue: 'analytics',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TabSliderPlainDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import TabSlider from '.'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'models', text: 'Models' },
|
||||
{ value: 'datasets', text: 'Datasets' },
|
||||
{ value: 'plugins', text: 'Plugins' },
|
||||
]
|
||||
|
||||
const TabSliderDemo = ({
|
||||
initialValue = 'models',
|
||||
}: {
|
||||
initialValue?: string
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
const originalFetch = globalThis.fetch?.bind(globalThis)
|
||||
|
||||
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
|
||||
if (url.includes('/workspaces/current/plugin/list')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
total: 6,
|
||||
plugins: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (originalFetch)
|
||||
return originalFetch(input, init)
|
||||
|
||||
throw new Error(`Unhandled request for ${url}`)
|
||||
}
|
||||
|
||||
globalThis.fetch = handler as typeof globalThis.fetch
|
||||
|
||||
return () => {
|
||||
if (originalFetch)
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Segmented tabs</div>
|
||||
<TabSlider
|
||||
value={value}
|
||||
options={OPTIONS}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/TabSlider',
|
||||
component: TabSliderDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Animated segmented control with sliding highlight. A badge appears when plugins are installed (mocked in Storybook).',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialValue: {
|
||||
control: 'radio',
|
||||
options: OPTIONS.map(option => option.value),
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialValue: 'models',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TabSliderDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import TagInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/TagInput',
|
||||
title: 'Base/Data Entry/TagInput',
|
||||
component: TagInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import TagManagementModal from '.'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import { useStore as useTagStore } from './store'
|
||||
import type { Tag } from './constant'
|
||||
|
||||
const INITIAL_TAGS: Tag[] = [
|
||||
{ id: 'tag-product', name: 'Product', type: 'app', binding_count: 12 },
|
||||
{ id: 'tag-growth', name: 'Growth', type: 'app', binding_count: 4 },
|
||||
{ id: 'tag-beta', name: 'Beta User', type: 'app', binding_count: 2 },
|
||||
{ id: 'tag-rag', name: 'RAG', type: 'knowledge', binding_count: 3 },
|
||||
{ id: 'tag-updates', name: 'Release Notes', type: 'knowledge', binding_count: 6 },
|
||||
]
|
||||
|
||||
const TagManagementPlayground = ({
|
||||
type = 'app',
|
||||
}: {
|
||||
type?: 'app' | 'knowledge'
|
||||
}) => {
|
||||
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
|
||||
const tagsRef = useRef<Tag[]>(INITIAL_TAGS)
|
||||
const setTagList = useTagStore(s => s.setTagList)
|
||||
const showModal = useTagStore(s => s.showTagManagementModal)
|
||||
const setShowModal = useTagStore(s => s.setShowTagManagementModal)
|
||||
|
||||
useEffect(() => {
|
||||
setTagList(tagsRef.current)
|
||||
setShowModal(true)
|
||||
}, [setTagList, setShowModal])
|
||||
|
||||
useEffect(() => {
|
||||
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
|
||||
|
||||
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = request.url
|
||||
const method = request.method.toUpperCase()
|
||||
const parsedUrl = new URL(url, window.location.origin)
|
||||
|
||||
if (parsedUrl.pathname.endsWith('/tags')) {
|
||||
if (method === 'GET') {
|
||||
const tagType = parsedUrl.searchParams.get('type') || 'app'
|
||||
const payload = tagsRef.current.filter(tag => tag.type === tagType)
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const body = await request.clone().json() as { name: string; type: string }
|
||||
const newTag: Tag = {
|
||||
id: `tag-${Date.now()}`,
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
binding_count: 0,
|
||||
}
|
||||
tagsRef.current = [newTag, ...tagsRef.current]
|
||||
setTagList(tagsRef.current)
|
||||
return new Response(JSON.stringify(newTag), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedUrl.pathname.endsWith('/tag-bindings/create') || parsedUrl.pathname.endsWith('/tag-bindings/remove')) {
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (originalFetchRef.current)
|
||||
return originalFetchRef.current(request)
|
||||
|
||||
throw new Error(`Unhandled request in mock fetch: ${url}`)
|
||||
}
|
||||
|
||||
globalThis.fetch = handler as typeof globalThis.fetch
|
||||
|
||||
return () => {
|
||||
if (originalFetchRef.current)
|
||||
globalThis.fetch = originalFetchRef.current
|
||||
}
|
||||
}, [setTagList])
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<button
|
||||
type="button"
|
||||
className="self-start rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
Manage tags
|
||||
</button>
|
||||
<p className="text-xs text-text-tertiary">Mocked tag management flows with create and bind actions.</p>
|
||||
</div>
|
||||
<TagManagementModal show={showModal} type={type} />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/TagManagementModal',
|
||||
component: TagManagementPlayground,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Complete tag management modal with mocked service calls for browsing and creating tags.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'radio',
|
||||
options: ['app', 'knowledge'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
type: 'app',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TagManagementPlayground>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Tag from '.'
|
||||
|
||||
const COLORS: Array<NonNullable<React.ComponentProps<typeof Tag>['color']>> = ['green', 'yellow', 'red', 'gray']
|
||||
|
||||
const TagGallery = ({
|
||||
bordered = false,
|
||||
hideBg = false,
|
||||
}: {
|
||||
bordered?: boolean
|
||||
hideBg?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Tag variants</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{COLORS.map(color => (
|
||||
<div key={color} className="flex flex-col items-start gap-2 rounded-xl border border-transparent px-3 py-2 hover:border-divider-subtle hover:bg-background-default-subtle">
|
||||
<Tag color={color} bordered={bordered} hideBg={hideBg}>
|
||||
{color.charAt(0).toUpperCase() + color.slice(1)}
|
||||
</Tag>
|
||||
<span className="text-[11px] uppercase tracking-[0.16em] text-text-quaternary">{color}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Tag',
|
||||
component: TagGallery,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Color-coded label component. Toggle borders or remove background to fit dark/light surfaces.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
bordered: { control: 'boolean' },
|
||||
hideBg: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
bordered: false,
|
||||
hideBg: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TagGallery>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const Outlined: Story = {
|
||||
args: {
|
||||
bordered: true,
|
||||
hideBg: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useState } from 'react'
|
|||
import Textarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/Textarea',
|
||||
title: 'Base/Data Entry/Textarea',
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useCallback } from 'react'
|
||||
import Toast, { ToastProvider, useToastContext } from '.'
|
||||
|
||||
const ToastControls = () => {
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const trigger = useCallback((type: 'success' | 'error' | 'warning' | 'info') => {
|
||||
notify({
|
||||
type,
|
||||
message: `This is a ${type} toast`,
|
||||
children: type === 'info' ? 'Additional details can live here.' : undefined,
|
||||
})
|
||||
}, [notify])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('success')}
|
||||
>
|
||||
Success
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('info')}
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('warning')}
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => trigger('error')}
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ToastProviderDemo = () => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Toast provider</div>
|
||||
<ToastControls />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const StaticToastDemo = () => {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Static API</div>
|
||||
<button
|
||||
type="button"
|
||||
className="self-start rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
const handle = Toast.notify({
|
||||
type: 'success',
|
||||
message: 'Saved changes',
|
||||
duration: 2000,
|
||||
})
|
||||
setTimeout(() => handle.clear?.(), 2500)
|
||||
}}
|
||||
>
|
||||
Trigger Toast.notify()
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Toast',
|
||||
component: ToastProviderDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'ToastProvider based notifications and the static Toast.notify helper. Buttons showcase each toast variant.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ToastProviderDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Provider: Story = {}
|
||||
|
||||
export const StaticApi: Story = {
|
||||
render: () => <StaticToastDemo />,
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Tooltip from '.'
|
||||
|
||||
const TooltipGrid = () => {
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Hover tooltips</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Tooltip popupContent="Helpful hint explaining the setting.">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
Hover me
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent="Placement can vary." position="right">
|
||||
<span className="rounded-md bg-background-default px-3 py-1 text-xs text-text-secondary">
|
||||
Right tooltip
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Click tooltips</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Tooltip popupContent="Click again to close." triggerMethod="click" position="bottom-start">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
Click trigger
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent="Decoration disabled" triggerMethod="click" noDecoration>
|
||||
<span className="rounded-md border border-dashed border-divider-regular px-3 py-1 text-xs text-text-secondary">
|
||||
Plain content
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/Tooltip',
|
||||
component: TooltipGrid,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Portal-based tooltip component supporting hover and click triggers, custom placements, and decorated content.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TooltipGrid>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import VideoGallery from '.'
|
||||
|
||||
const VIDEO_SOURCES = [
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/forest.mp4',
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/VideoGallery',
|
||||
component: VideoGallery,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Stacked list of video players with custom controls for progress, volume, and fullscreen.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<VideoGallery
|
||||
srcs={[
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/forest.mp4',
|
||||
]}
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
srcs: VIDEO_SOURCES,
|
||||
},
|
||||
} satisfies Meta<typeof VideoGallery>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
|
@ -81,7 +81,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => {
|
|||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/VoiceInput',
|
||||
title: 'Base/Data Entry/VoiceInput',
|
||||
component: VoiceInputMock,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ const ValidatedUserCard = withValidation(UserCard, userSchema)
|
|||
const ValidatedProductCard = withValidation(ProductCard, productSchema)
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input/WithInputValidation',
|
||||
title: 'Base/Data Entry/WithInputValidation',
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue