Compare commits

..

15 Commits

Author SHA1 Message Date
yyh
4dc15d39b1 style(dify-ui): merge pagination className with cn 2026-05-24 19:39:06 +08:00
yyh
cd1cb8f175 style 2026-05-24 19:00:37 +08:00
2dd683e613 [autofix.ci] apply automated fixes 2026-05-24 10:13:52 +00:00
yyh
7489893ea4 style 2026-05-24 18:06:52 +08:00
yyh
9dfb9a4b4d style 2026-05-24 17:55:35 +08:00
yyh
1012d84242 style 2026-05-24 17:32:00 +08:00
yyh
80ccec9457 fix(web): align common i18n keys 2026-05-24 17:26:34 +08:00
yyh
d8a48bc62f fix 2026-05-24 17:24:49 +08:00
yyh
32270c3e63 fix(dify-ui): align pagination composition and input behavior 2026-05-24 17:06:35 +08:00
yyh
c6291e928d style 2026-05-24 16:46:56 +08:00
yyh
687377cc76 fix(dify-ui): align pagination jump styles with design 2026-05-24 16:45:27 +08:00
yyh
fa730139a6 fix(dify-ui): rely on number field commits for page jump 2026-05-24 16:38:45 +08:00
yyh
5fd873d033 fix(web): preserve pagination controls after page size changes 2026-05-24 16:36:26 +08:00
yyh
fb6a495fa5 refactor(web): migrate to dify-ui pagination 2026-05-24 16:34:39 +08:00
yyh
99d5f80e68 feat(dify-ui): add pagination primitive 2026-05-24 16:23:31 +08:00
53 changed files with 1494 additions and 1903 deletions

View File

@ -1571,19 +1571,6 @@
"count": 1
}
},
"web/app/components/base/pagination/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"unicorn/prefer-number-properties": {
"count": 1
}
},
"web/app/components/base/pagination/type.ts": {
"ts/no-empty-object-type": {
"count": 1
}
},
"web/app/components/base/prompt-editor/index.stories.tsx": {
"no-console": {
"count": 1

View File

@ -40,16 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./pagination`, `./tabs`, `./toggle-group` | Pagination for page navigation; Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:

View File

@ -77,6 +77,10 @@
"types": "./src/number-field/index.tsx",
"import": "./src/number-field/index.tsx"
},
"./pagination": {
"types": "./src/pagination/index.tsx",
"import": "./src/pagination/index.tsx"
},
"./radio": {
"types": "./src/radio/index.tsx",
"import": "./src/radio/index.tsx"

View File

@ -0,0 +1,293 @@
import { render } from 'vitest-browser-react'
import {
Pagination,
PaginationContent,
PaginationNavigation,
PaginationNext,
PaginationPage,
PaginationPageJump,
PaginationPageList,
PaginationPageSize,
PaginationPrevious,
PaginationRoot,
PaginationSkeleton,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
async function renderPagination({
page = 2,
totalPages = 200,
onPageChange = vi.fn(),
pageSize = 25,
onPageSizeChange = vi.fn(),
}: {
page?: number
totalPages?: number
onPageChange?: (page: number) => void
pageSize?: number
onPageSizeChange?: (pageSize: number) => void
} = {}) {
const screen = await render(
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
data-testid="pagination"
>
<PaginationContent data-testid="content">
<PaginationNavigation data-testid="controls">
<PaginationPrevious />
<PaginationPageJump />
<PaginationNext />
</PaginationNavigation>
<PaginationPageList data-testid="pages" />
<PaginationPageSize
value={pageSize}
options={[10, 25, 50]}
onValueChange={onPageSizeChange}
/>
</PaginationContent>
</PaginationRoot>,
)
return {
screen,
onPageChange,
onPageSizeChange,
}
}
describe('Pagination primitive', () => {
it('renders the Figma-aligned pagination structure with semantic navigation', async () => {
const { screen } = await renderPagination()
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '2')
await expect.element(screen.getByTestId('content')).toHaveClass('grid', 'grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
await expect.element(screen.getByTestId('controls')).toHaveClass('justify-self-start', 'rounded-[10px]', 'bg-background-section-burn')
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
expect(screen.getByRole('group', { name: 'Items per page' }).element().parentElement).toHaveClass('col-start-3', 'justify-self-end')
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveTextContent('2/200')
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveClass('h-7', 'px-2')
expect(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).not.toHaveClass('min-w-14')
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveAttribute('aria-current', 'page')
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveClass('bg-components-button-tertiary-bg')
await expect.element(screen.getByText('…')).toBeInTheDocument()
})
it('uses one-based page changes for previous, next, and page buttons', async () => {
const { screen, onPageChange } = await renderPagination({ page: 4 })
asHTMLElement(screen.getByRole('button', { name: 'Previous page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Next page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Go to page 6' }).element()).click()
expect(onPageChange).toHaveBeenNthCalledWith(1, 3)
expect(onPageChange).toHaveBeenNthCalledWith(2, 5)
expect(onPageChange).toHaveBeenNthCalledWith(3, 6)
})
it('disables previous at the first page', async () => {
const { screen } = await renderPagination({ page: 1, totalPages: 10 })
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
})
it('disables next at the last page', async () => {
const { screen } = await renderPagination({ page: 10, totalPages: 10 })
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
})
it('clamps invalid root page values without exposing invalid state', async () => {
const { screen } = await renderPagination({ page: 999, totalPages: 10 })
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '10')
await expect.element(screen.getByRole('button', { name: 'Page 10, current page' })).toHaveAttribute('aria-current', 'page')
})
it('switches the page summary into a selected labelled number field', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue('2')
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveClass('text-center', 'tabular-nums')
expect(input.parentElement?.parentElement?.parentElement).toHaveAttribute('data-page-summary', '2/200')
await vi.waitFor(() => {
expect(input.selectionStart).toBe(0)
expect(input.selectionEnd).toBe(1)
})
})
it('returns to the summary button when the page input loses focus', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()).blur()
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
})
it('commits the page input editing mode with Enter', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await vi.waitFor(() => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}))
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
})
it('cancels the page input editing mode with Escape', async () => {
const { screen, onPageChange } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await vi.waitFor(() => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}))
const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })
await expect.element(summaryButton).toBeInTheDocument()
await vi.waitFor(() => {
expect(document.activeElement).toBe(summaryButton.element())
})
expect(onPageChange).not.toHaveBeenCalled()
})
it('uses Base UI ToggleGroup semantics for page size', async () => {
const { screen, onPageSizeChange } = await renderPagination()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toHaveClass('bg-components-segmented-control-bg-normal')
await expect.element(screen.getByText('Items per page')).toHaveClass('opacity-0', 'group-hover/page-size:opacity-100', 'group-focus-within/page-size:opacity-100')
await expect.element(screen.getByRole('button', { name: '25' })).toHaveAttribute('aria-pressed', 'true')
await expect.element(screen.getByRole('button', { name: '25' })).toHaveClass('data-pressed:text-text-primary')
asHTMLElement(screen.getByRole('button', { name: '50' }).element()).click()
expect(onPageSizeChange).toHaveBeenCalledWith(50)
})
it('renders the complete pagination bar with optional page size controls', async () => {
const onPageSizeChange = vi.fn()
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
pageSize={{
value: 25,
options: [10, 25, 50],
onValueChange: onPageSizeChange,
}}
/>,
)
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toBeInTheDocument()
})
it('uses a localized action label for editing the page number', async () => {
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
labels={{
editPageNumber: (page, totalPages) => `Change page, current page ${page} of ${totalPages}`,
}}
/>,
)
await expect.element(screen.getByRole('button', { name: 'Change page, current page 2 of 10' })).toBeInTheDocument()
})
it('keeps facade page numbers centered when page size controls are omitted', async () => {
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
/>,
)
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
expect(screen.container.querySelector('nav[aria-label="Pagination"] > div')).toHaveClass('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
})
it('does not expose invalid page controls when there are no pages', async () => {
const screen = await render(
<Pagination
page={1}
totalPages={0}
onPageChange={vi.fn()}
/>,
)
expect(screen.container.querySelector('nav[aria-label="Pagination"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
})
it('omits compound page jump and page list content for empty pagination state', async () => {
const { screen } = await renderPagination({ page: 1, totalPages: 0 })
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '1')
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label="Previous page"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label="Next page"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('ol')).not.toBeInTheDocument()
})
it('allows custom page rendering while keeping the shared context', async () => {
const onPageChange = vi.fn()
const screen = await render(
<PaginationRoot page={3} totalPages={5} onPageChange={onPageChange}>
<ol>
<li>
<PaginationPage page={4} className="custom-page">
Four
</PaginationPage>
</li>
</ol>
</PaginationRoot>,
)
asHTMLElement(screen.getByRole('button', { name: 'Go to page 4' }).element()).click()
await expect.element(screen.getByRole('button', { name: 'Go to page 4' })).toHaveClass('custom-page')
expect(onPageChange).toHaveBeenCalledWith(4)
})
it('renders a non-interactive loading skeleton', async () => {
const screen = await render(<PaginationSkeleton data-testid="skeleton" />)
await expect.element(screen.getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true')
await expect.element(screen.getByTestId('skeleton')).toHaveClass('select-none')
})
})

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import {
Pagination,
PaginationSkeleton,
} from '.'
function PaginationExample({
initialPage = 2,
initialPageSize = 25,
totalPages = 200,
}: {
initialPage?: number
initialPageSize?: number
totalPages?: number
}) {
const [page, setPage] = useState(initialPage)
const [pageSize, setPageSize] = useState(initialPageSize)
return (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
pageSize={{
value: pageSize,
options: [10, 25, 50],
onValueChange: setPageSize,
}}
/>
)
}
function PaginationDemo(props: ComponentProps<typeof PaginationExample>) {
return (
<div className="w-236 max-w-full bg-components-panel-bg px-16 py-10">
<PaginationExample {...props} />
</div>
)
}
function DesignSpecDemo() {
return (
<div className="flex w-236 max-w-full flex-col gap-6 bg-components-panel-bg px-16 py-10">
<PaginationExample />
<PaginationExample initialPage={2} initialPageSize={25} />
<PaginationExample initialPage={2} initialPageSize={25} />
<PaginationExample initialPage={2} initialPageSize={25} />
</div>
)
}
const meta = {
title: 'Base/UI/Pagination',
component: PaginationDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a ToggleGroup-backed page-size selector.',
},
},
},
args: {
initialPage: 2,
initialPageSize: 25,
totalPages: 200,
},
tags: ['autodocs'],
} satisfies Meta<typeof PaginationDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {
render: () => <PaginationDemo />,
}
export const DesignSpec: Story = {
render: () => <DesignSpecDemo />,
parameters: {
docs: {
description: {
story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.',
},
},
},
}
export const Loading: Story = {
render: () => <PaginationSkeleton />,
}

View File

@ -0,0 +1,655 @@
'use client'
import type { Button as BaseButtonNS } from '@base-ui/react/button'
import type { ReactNode } from 'react'
import { Button as BaseButton } from '@base-ui/react/button'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { createContext, useContext, useMemo, useRef, useState } from 'react'
import { cn } from '../cn'
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
} from '../number-field'
import {
ToggleGroup,
ToggleGroupItem,
} from '../toggle-group'
type PageItem = number | 'ellipsis-start' | 'ellipsis-end'
type PaginationContextValue = {
page: number
totalPages: number
hasPages: boolean
disabled: boolean
onPageChange: (page: number) => void
items: PageItem[]
}
const PaginationContext = createContext<PaginationContextValue | null>(null)
function usePaginationContext(component: string) {
const context = useContext(PaginationContext)
if (!context)
throw new Error(`${component} must be used inside PaginationRoot.`)
return context
}
function clampPage(page: number, totalPages: number) {
if (!Number.isFinite(page))
return 1
return Math.min(Math.max(Math.trunc(page), 1), Math.max(totalPages, 1))
}
function range(start: number, end: number) {
if (end < start)
return []
return Array.from({ length: end - start + 1 }, (_, index) => start + index)
}
type GetPageItemsOptions = {
page: number
totalPages: number
siblingCount: number
boundaryCount: number
visiblePageCount: number
}
function getPageItems({
page,
totalPages,
siblingCount,
boundaryCount,
visiblePageCount,
}: GetPageItemsOptions): PageItem[] {
if (totalPages <= 0)
return []
const normalizedPage = clampPage(page, totalPages)
const normalizedBoundaryCount = Math.max(Math.trunc(boundaryCount), 1)
const normalizedSiblingCount = Math.max(Math.trunc(siblingCount), 0)
const windowSize = Math.max(
Math.trunc(visiblePageCount),
normalizedSiblingCount * 2 + 1,
)
if (totalPages <= windowSize + normalizedBoundaryCount)
return range(1, totalPages)
const nearStartEnd = windowSize
const nearEndStart = totalPages - windowSize + 1
const middleStart = Math.max(
normalizedBoundaryCount + 1,
normalizedPage - normalizedSiblingCount,
)
const middleEnd = Math.min(
totalPages - normalizedBoundaryCount,
normalizedPage + normalizedSiblingCount,
)
const windowPages = normalizedPage <= nearStartEnd - normalizedSiblingCount
? range(1, nearStartEnd)
: normalizedPage >= nearEndStart + normalizedSiblingCount
? range(nearEndStart, totalPages)
: range(middleStart, middleEnd)
const pageSet = new Set([
...range(1, normalizedBoundaryCount),
...windowPages,
...range(totalPages - normalizedBoundaryCount + 1, totalPages),
])
const pages = Array.from(pageSet)
.filter(item => item >= 1 && item <= totalPages)
.sort((a, b) => a - b)
return pages.reduce<PageItem[]>((items, item, index) => {
const previous = pages[index - 1]
if (previous && item - previous === 2)
items.push(previous + 1)
else if (previous && item - previous > 2)
items.push(item < normalizedPage ? 'ellipsis-start' : 'ellipsis-end')
items.push(item)
return items
}, [])
}
type PaginationRootState = {
page: number
totalPages: number
hasPages: boolean
disabled: boolean
}
export type PaginationRootProps = Omit<
useRender.ComponentProps<'nav', PaginationRootState>,
'onChange'
> & {
page: number
totalPages: number
onPageChange: (page: number) => void
siblingCount?: number
boundaryCount?: number
visiblePageCount?: number
}
export function PaginationRoot({
page,
totalPages,
onPageChange,
siblingCount = 1,
boundaryCount = 1,
visiblePageCount = 8,
render,
children,
className,
...props
}: PaginationRootProps) {
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
const normalizedPage = clampPage(page, normalizedTotalPages)
const hasPages = normalizedTotalPages > 0
const disabled = normalizedTotalPages <= 1
const items = useMemo(() => getPageItems({
page: normalizedPage,
totalPages: normalizedTotalPages,
siblingCount,
boundaryCount,
visiblePageCount,
}), [
boundaryCount,
normalizedPage,
normalizedTotalPages,
siblingCount,
visiblePageCount,
])
const context = useMemo<PaginationContextValue>(() => ({
page: normalizedPage,
totalPages: normalizedTotalPages,
hasPages,
disabled,
onPageChange: nextPage => onPageChange(clampPage(nextPage, normalizedTotalPages)),
items,
}), [disabled, hasPages, items, normalizedPage, normalizedTotalPages, onPageChange])
const defaultProps: useRender.ElementProps<'nav'> = {
'aria-label': 'Pagination',
'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className),
'children': (
<PaginationContext.Provider value={context}>
{children}
</PaginationContext.Provider>
),
}
return useRender({
defaultTagName: 'nav',
render,
state: {
page: normalizedPage,
totalPages: normalizedTotalPages,
hasPages,
disabled,
},
props: mergeProps<'nav'>(defaultProps, props),
})
}
export type PaginationNavigationProps = useRender.ComponentProps<'div'>
export type PaginationContentProps = useRender.ComponentProps<'div'>
export function PaginationContent({
render,
className,
...props
}: PaginationContentProps) {
const defaultProps: useRender.ElementProps<'div'> = {
className: cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2', className),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}
export function PaginationNavigation({
render,
className,
...props
}: PaginationNavigationProps) {
const defaultProps: useRender.ElementProps<'div'> = {
className: cn('flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5', className),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}
type PaginationButtonProps = Omit<BaseButtonNS.Props, 'children'> & {
children?: ReactNode
}
const paginationArrowButtonClassName = [
'inline-flex size-7 shrink-0 touch-manipulation items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[10px] transition-[background-color,border-color,color,box-shadow]',
'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
'focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
'disabled:cursor-not-allowed disabled:border-components-button-secondary-border-disabled disabled:bg-components-button-secondary-bg-disabled disabled:text-components-button-secondary-text-disabled disabled:shadow-none',
'motion-reduce:transition-none',
]
export function PaginationPrevious({
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationButtonProps) {
const pagination = usePaginationContext('PaginationPrevious')
if (!pagination.hasPages)
return null
const disabled = props.disabled || pagination.page <= 1 || pagination.disabled
return (
<BaseButton
{...props}
type="button"
aria-label={ariaLabel ?? 'Previous page'}
className={cn(paginationArrowButtonClassName, className)}
disabled={disabled}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented && !disabled)
pagination.onPageChange(pagination.page - 1)
}}
>
{children ?? <span className="i-ri-arrow-left-line size-4" aria-hidden="true" />}
</BaseButton>
)
}
export function PaginationNext({
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationButtonProps) {
const pagination = usePaginationContext('PaginationNext')
if (!pagination.hasPages)
return null
const disabled = props.disabled || pagination.page >= pagination.totalPages || pagination.disabled
return (
<BaseButton
{...props}
type="button"
aria-label={ariaLabel ?? 'Next page'}
className={cn(paginationArrowButtonClassName, className)}
disabled={disabled}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented && !disabled)
pagination.onPageChange(pagination.page + 1)
}}
>
{children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />}
</BaseButton>
)
}
export type PaginationPageJumpProps = Omit<BaseButtonNS.Props, 'children'> & {
inputLabel?: string
children?: ReactNode
}
export function PaginationPageJump({
className,
inputLabel = 'Page number',
children,
'aria-label': ariaLabel,
...props
}: PaginationPageJumpProps) {
const pagination = usePaginationContext('PaginationPageJump')
const [editing, setEditing] = useState(false)
const summaryButtonRef = useRef<HTMLButtonElement | null>(null)
if (!pagination.hasPages)
return null
if (editing) {
return (
<span
data-page-summary={`${pagination.page}/${pagination.totalPages}`}
className="inline-grid h-7 system-xs-medium tabular-nums after:invisible after:col-start-1 after:row-start-1 after:py-1.5 after:pr-3 after:pl-2 after:content-[attr(data-page-summary)]"
>
<NumberField
key={pagination.page}
className="col-start-1 row-start-1 w-full"
defaultValue={pagination.page}
min={1}
max={Math.max(pagination.totalPages, 1)}
onValueCommitted={(value) => {
if (value !== null)
pagination.onPageChange(value)
setEditing(false)
}}
>
<NumberFieldGroup
className="h-7 w-full min-w-0 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-active shadow-xs"
>
<NumberFieldInput
aria-label={inputLabel}
autoFocus
className="px-2 py-1.5 text-center system-xs-medium tabular-nums"
onBlur={() => requestAnimationFrame(() => setEditing(false))}
onFocus={(event) => {
const input = event.currentTarget
requestAnimationFrame(() => input.select())
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
return
}
if (event.key === 'Escape') {
event.preventDefault()
setEditing(false)
requestAnimationFrame(() => summaryButtonRef.current?.focus())
}
}}
/>
</NumberFieldGroup>
</NumberField>
</span>
)
}
return (
<BaseButton
{...props}
ref={summaryButtonRef}
type="button"
aria-label={ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}`}
className={cn(
'inline-flex h-7 touch-manipulation items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 system-xs-medium tabular-nums text-text-secondary outline-hidden transition-colors hover:cursor-text hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-components-input-border-hover motion-reduce:transition-none',
className,
)}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented)
setEditing(true)
}}
>
{children ?? (
<>
<span>{pagination.page}</span>
<span className="text-text-quaternary">/</span>
<span>{pagination.totalPages}</span>
</>
)}
</BaseButton>
)
}
export type PaginationPageListProps = useRender.ComponentProps<'ol'>
export function PaginationPageList({
render,
className,
...props
}: PaginationPageListProps) {
const pagination = usePaginationContext('PaginationPageList')
if (!pagination.hasPages)
return null
const defaultProps: useRender.ElementProps<'ol'> = {
className: cn('col-start-2 flex min-w-0 list-none items-center justify-self-center gap-0.5', className),
children: pagination.items.map(item => (
<li key={item}>
{typeof item === 'number'
? <PaginationPage page={item} />
: <PaginationEllipsis />}
</li>
)),
}
return useRender({
defaultTagName: 'ol',
render,
props: mergeProps<'ol'>(defaultProps, props),
})
}
export type PaginationPageProps = Omit<BaseButtonNS.Props, 'children'> & {
page: number
children?: ReactNode
}
export function PaginationPage({
page,
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationPageProps) {
const pagination = usePaginationContext('PaginationPage')
const current = page === pagination.page
return (
<BaseButton
{...props}
type="button"
aria-current={current ? 'page' : undefined}
aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)}
className={cn(
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover',
'motion-reduce:transition-none',
className,
)}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented)
pagination.onPageChange(page)
}}
>
{children ?? page}
</BaseButton>
)
}
export type PaginationEllipsisProps = useRender.ComponentProps<'span'>
export function PaginationEllipsis({
render,
className,
...props
}: PaginationEllipsisProps) {
const defaultProps: useRender.ElementProps<'span'> = {
'aria-hidden': true,
'className': cn('flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary', className),
'children': '…',
}
return useRender({
defaultTagName: 'span',
render,
props: mergeProps<'span'>(defaultProps, props),
})
}
export type PaginationPageSizeProps<Value extends number = number> = {
'value': Value
'options': readonly Value[]
'onValueChange': (value: Value) => void
'label'?: ReactNode
'aria-label'?: string
'className'?: string
}
export function PaginationPageSize<Value extends number = number>({
value,
options,
onValueChange,
label = 'Items per page',
'aria-label': ariaLabel = 'Items per page',
className,
}: PaginationPageSizeProps<Value>) {
return (
<div className={cn('group/page-size col-start-3 flex shrink-0 items-center justify-end justify-self-end gap-2', className)}>
<div className="w-13 shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary opacity-0 transition-opacity group-hover/page-size:opacity-100 group-focus-within/page-size:opacity-100 motion-reduce:transition-none">
{label}
</div>
<ToggleGroup
value={[String(value)]}
aria-label={ariaLabel}
onValueChange={(nextValue) => {
const [selectedValue] = nextValue
if (!selectedValue)
return
const selectedOption = options.find(option => String(option) === selectedValue)
if (selectedOption !== undefined)
onValueChange(selectedOption)
}}
>
{options.map(option => (
<ToggleGroupItem
key={option}
value={String(option)}
className="min-w-9 data-pressed:text-text-primary"
>
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
export type PaginationLabels = {
previous?: string
next?: string
editPageNumber?: (page: number, totalPages: number) => string
pageNumberInput?: string
}
export type PaginationPageSizeConfig<Value extends number = number> = {
value: Value
options: readonly Value[]
onValueChange: (value: Value) => void
label?: ReactNode
ariaLabel?: string
}
export type PaginationProps<Value extends number = number> = Omit<PaginationRootProps, 'children'> & {
labels?: PaginationLabels
pageSize?: PaginationPageSizeConfig<Value>
}
export function Pagination<Value extends number = number>({
labels,
pageSize,
page,
totalPages,
onPageChange,
...props
}: PaginationProps<Value>) {
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
const normalizedPage = clampPage(page, normalizedTotalPages)
const editPageNumber = labels?.editPageNumber?.(normalizedPage, normalizedTotalPages)
if (normalizedTotalPages <= 0)
return null
return (
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
{...props}
>
<PaginationContent>
<PaginationNavigation>
<PaginationPrevious aria-label={labels?.previous} />
<PaginationPageJump
aria-label={editPageNumber}
inputLabel={labels?.pageNumberInput}
/>
<PaginationNext aria-label={labels?.next} />
</PaginationNavigation>
<PaginationPageList />
{pageSize && (
<PaginationPageSize
value={pageSize.value}
options={pageSize.options}
onValueChange={pageSize.onValueChange}
label={pageSize.label}
aria-label={pageSize.ariaLabel}
/>
)}
</PaginationContent>
</PaginationRoot>
)
}
export type PaginationSkeletonProps = useRender.ComponentProps<'div'>
export function PaginationSkeleton({
render,
className,
...props
}: PaginationSkeletonProps) {
const defaultProps: useRender.ElementProps<'div'> = {
'aria-hidden': true,
'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className),
'children': (
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
<div className="flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
<div className="h-7 min-w-14 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
</div>
<div className="col-start-2 flex items-center justify-self-center gap-0.5">
{range(1, 8).map(item => (
<div key={item} className="h-8 min-w-8 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
))}
</div>
<div className="col-start-3 flex shrink-0 items-center justify-self-end">
<div className="h-8 w-28 animate-pulse rounded-[10px] bg-state-base-hover motion-reduce:animate-none" />
</div>
</div>
),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}

View File

@ -10,7 +10,11 @@ export default defineConfig({
tsconfigPaths: true,
},
optimizeDeps: {
include: ['@base-ui/react/form'],
include: [
'@base-ui/react/form',
'@base-ui/react/merge-props',
'@base-ui/react/use-render',
],
},
test: {
globals: true,

View File

@ -5,7 +5,6 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import Avatar from './avatar'
@ -18,26 +17,21 @@ const Header = () => {
const goToStudio = useCallback(() => {
router.push('/apps')
}, [router])
const logoLabel = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
return (
<div className="flex flex-1 items-center justify-between px-4">
<div className="flex items-center gap-3">
<Link
href="/apps"
className="flex items-center rounded-sm hover:opacity-80 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={logoLabel}
>
<div className="flex cursor-pointer items-center" onClick={goToStudio}>
{systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo
? (
<img
src={systemFeatures.branding.login_page_logo}
className="block h-[22px] w-auto object-contain"
alt=""
alt="Dify logo"
/>
)
: <DifyLogo alt="" />}
</Link>
: <DifyLogo />}
</div>
<div className="h-4 w-px origin-center rotate-[11.31deg] bg-divider-regular" />
<p className="relative mt-[-2px] title-3xl-semi-bold text-text-primary">{t('account.account', { ns: 'common' })}</p>
</div>

View File

@ -5,6 +5,7 @@ import type { AnnotationItem, AnnotationItemBasic } from './type'
import type { AnnotationReplyConfig } from '@/models/debug'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiEqualizer2Line } from '@remixicon/react'
@ -16,7 +17,6 @@ import ActionButton from '@/app/components/base/action-button'
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { APP_PAGE_LIMIT } from '@/config'
import { useProviderContext } from '@/context/provider-context'
@ -49,6 +49,7 @@ const Annotation: FC<Props> = (props) => {
const [limit, setLimit] = useState(APP_PAGE_LIMIT)
const [list, setList] = useState<AnnotationItem[]>([])
const [total, setTotal] = useState(0)
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const [isLoading, setIsLoading] = useState(false)
const [controlUpdateList, setControlUpdateList] = useState(() => Date.now())
const [currItem, setCurrItem] = useState<AnnotationItem | null>(null)
@ -217,11 +218,22 @@ const Annotation: FC<Props> = (props) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={setCurrPage}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -20,12 +20,12 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Pagination } from '@langgenius/dify-ui/pagination'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import Pagination from '@/app/components/base/pagination'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { APP_PAGE_LIMIT } from '@/config'
import useTimestamp from '@/hooks/use-timestamp'
@ -62,6 +62,7 @@ const ViewAnnotationModal: FC<Props> = ({
const { formatTime } = useTimestamp()
const [currPage, setCurrPage] = React.useState<number>(0)
const [total, setTotal] = useState(0)
const totalPages = total ? Math.max(Math.ceil(total / APP_PAGE_LIMIT), 1) : 1
const [hitHistoryList, setHitHistoryList] = useState<HitHistoryItem[]>([])
// Update local state when item prop changes (e.g., when modal is reopened with updated data)
@ -197,10 +198,15 @@ const ViewAnnotationModal: FC<Props> = ({
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
className="px-0"
current={currPage}
onChange={setCurrPage}
total={total}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -67,9 +67,11 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading-logs</div>,
}))
vi.mock('@/app/components/base/pagination', () => ({
default: ({ onChange }: { onChange: (page: number) => void }) => (
<button onClick={() => onChange(1)}>go-to-page-2</button>
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({ onPageChange }: { onPageChange: (page: number) => void }) => (
<div>
<button onClick={() => onPageChange(2)}>go-to-page-2</button>
</div>
),
}))

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'es-toolkit/object'
@ -8,7 +9,6 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
@ -98,6 +98,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
})
const total = isChatMode ? chatConversations?.total : completionConversations?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const handleQueryParamsChange = useCallback((next: QueryParam) => {
setCurrPage(0)
@ -130,11 +131,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={handlePageChange}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => handlePageChange(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
@ -11,7 +12,6 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import EmptyElement from '@/app/components/app/log/empty-element'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useWorkflowLogs } from '@/service/use-log'
@ -59,6 +59,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
params: query,
})
const total = workflowLogs?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
return (
<div className="flex h-full flex-col">
@ -76,11 +77,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={setCurrPage}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -58,12 +58,6 @@ describe('DifyLogo', () => {
const img = screen.getByRole('img', { name: /dify logo/i })
expect(img).toHaveClass('custom-test-class')
})
it('applies custom alt text', () => {
const { container } = render(<DifyLogo alt="" />)
const img = container.querySelector('img')
expect(img).toHaveAttribute('alt', '')
})
})
describe('Theme behavior', () => {

View File

@ -23,14 +23,12 @@ type DifyLogoProps = {
style?: LogoStyle
size?: LogoSize
className?: string
alt?: string
}
const DifyLogo: FC<DifyLogoProps> = ({
style = 'default',
size = 'medium',
className,
alt = 'Dify logo',
}) => {
const { theme } = useTheme()
const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style
@ -39,7 +37,7 @@ const DifyLogo: FC<DifyLogoProps> = ({
<img
src={`${basePath}${logoPathMap[themedStyle]}`}
className={cn('block object-contain', logoSizeMap[size], className)}
alt={alt}
alt="Dify logo"
/>
)
}

View File

@ -1,155 +0,0 @@
import { renderHook } from '@testing-library/react'
import usePagination from '../hook'
const defaultProps = {
currentPage: 0,
setCurrentPage: vi.fn(),
totalPages: 10,
edgePageCount: 2,
middlePagesSiblingCount: 1,
truncableText: '...',
truncableClassName: 'truncable',
}
describe('usePagination', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('pages', () => {
it('should generate correct pages array', () => {
const { result } = renderHook(() => usePagination(defaultProps))
expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
it('should generate empty pages for totalPages 0', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 }))
expect(result.current.pages).toEqual([])
})
it('should generate single page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 }))
expect(result.current.pages).toEqual([1])
})
})
describe('hasPreviousPage / hasNextPage', () => {
it('should have no previous page on first page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
expect(result.current.hasPreviousPage).toBe(false)
})
it('should have previous page when not on first page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 }))
expect(result.current.hasPreviousPage).toBe(true)
})
it('should have next page when not on last page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
expect(result.current.hasNextPage).toBe(true)
})
it('should have no next page on last page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 }))
expect(result.current.hasNextPage).toBe(false)
})
})
describe('middlePages', () => {
it('should return correct middle pages when at start', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
// isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3)
expect(result.current.middlePages).toEqual([1, 2, 3])
})
it('should return correct middle pages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7]
expect(result.current.middlePages).toEqual([5, 6, 7])
})
it('should return correct middle pages when at end', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
// isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3)
expect(result.current.middlePages).toEqual([8, 9, 10])
})
})
describe('previousPages and nextPages', () => {
it('should return empty previousPages when at start', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
expect(result.current.previousPages).toEqual([])
})
it('should return previousPages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// edgePageCount=2, so first 2 pages filtered by not in middlePages
expect(result.current.previousPages).toEqual([1, 2])
})
it('should return empty nextPages when at end', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
expect(result.current.nextPages).toEqual([])
})
it('should return nextPages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7]
expect(result.current.nextPages).toEqual([9, 10])
})
})
describe('truncation', () => {
it('should be previous truncable when middle pages are far from edge', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true
expect(result.current.isPreviousTruncable).toBe(true)
})
it('should not be previous truncable when pages are contiguous', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 }))
expect(result.current.isPreviousTruncable).toBe(false)
})
it('should be next truncable when middle pages are far from end edge', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true
expect(result.current.isNextTruncable).toBe(true)
})
it('should not be next truncable when pages are contiguous', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 }))
expect(result.current.isNextTruncable).toBe(false)
})
})
describe('passthrough values', () => {
it('should pass through currentPage', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
expect(result.current.currentPage).toBe(5)
})
it('should pass through setCurrentPage', () => {
const setCurrentPage = vi.fn()
const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage }))
result.current.setCurrentPage(3)
expect(setCurrentPage).toHaveBeenCalledWith(3)
})
it('should pass through truncableText', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' }))
expect(result.current.truncableText).toBe('…')
})
it('should pass through truncableClassName', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' }))
expect(result.current.truncableClassName).toBe('custom-trunc')
})
it('should use default truncableText', () => {
const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps
const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount }))
expect(result.current.truncableText).toBe('...')
})
})
})

View File

@ -1,444 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import CustomizedPagination from '../index'
describe('CustomizedPagination', () => {
const defaultProps = {
current: 0,
onChange: vi.fn(),
total: 100,
}
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<CustomizedPagination {...defaultProps} />)
expect(container)!.toBeInTheDocument()
})
it('should display current page and total pages', () => {
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} />)
// current + 1 = 1, totalPages = 10
// The page info display shows "1 / 10" and page buttons also show numbers
// current + 1 = 1, totalPages = 10
// The page info display shows "1 / 10" and page buttons also show numbers
expect(screen.getByText('/'))!.toBeInTheDocument()
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1)
})
it('should render prev and next buttons', () => {
render(<CustomizedPagination {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
it('should render page number buttons', () => {
render(<CustomizedPagination {...defaultProps} total={50} limit={10} />)
// 5 pages total, should see page numbers
// 5 pages total, should see page numbers
expect(screen.getByText('2'))!.toBeInTheDocument()
expect(screen.getByText('3'))!.toBeInTheDocument()
})
it('should display slash separator between current page and total', () => {
render(<CustomizedPagination {...defaultProps} />)
expect(screen.getByText('/'))!.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<CustomizedPagination {...defaultProps} className="my-custom" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper)!.toHaveClass('my-custom')
})
it('should default limit to 10', () => {
render(<CustomizedPagination {...defaultProps} total={100} />)
// totalPages = 100 / 10 = 10, displayed in the page info area
expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1)
})
it('should calculate total pages based on custom limit', () => {
render(<CustomizedPagination {...defaultProps} total={100} limit={25} />)
// totalPages = 100 / 25 = 4, displayed in the page info area
expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1)
})
it('should disable prev button on first page', () => {
render(<CustomizedPagination {...defaultProps} current={0} />)
const buttons = screen.getAllByRole('button')
// First button is prev
// First button is prev
expect(buttons[0])!.toBeDisabled()
})
it('should disable next button on last page', () => {
render(<CustomizedPagination {...defaultProps} current={9} total={100} limit={10} />)
const buttons = screen.getAllByRole('button')
// Last button is next
// Last button is next
expect(buttons[buttons.length - 1])!.toBeDisabled()
})
it('should not render limit selector when onLimitChange is not provided', () => {
render(<CustomizedPagination {...defaultProps} />)
expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument()
})
it('should render limit selector when onLimitChange is provided', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
// Should show limit options 10, 25, 50
// Should show limit options 10, 25, 50
expect(screen.getByText('25'))!.toBeInTheDocument()
expect(screen.getByText('50'))!.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange when next button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
const buttons = screen.getAllByRole('button')
const nextButton = buttons[buttons.length - 1]
fireEvent.click(nextButton!)
expect(onChange).toHaveBeenCalledWith(1)
})
it('should call onChange when prev button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0]!)
expect(onChange).toHaveBeenCalledWith(4)
})
it('should show input when page display is clicked', () => {
render(<CustomizedPagination {...defaultProps} />)
// Click the current page display (the div containing "1 / 10")
fireEvent.click(screen.getByText('/'))
// Input should appear
// Input should appear
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should navigate to entered page on Enter key', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '5' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(4) // 0-indexed
})
it('should cancel input on Escape key', () => {
render(<CustomizedPagination {...defaultProps} current={0} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Escape' })
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('/'))!.toBeInTheDocument()
})
it('should confirm input on blur-sm', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '3' } })
fireEvent.blur(input)
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
})
it('should clamp page to max when input exceeds total pages', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '999' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed)
})
it('should clamp page to min when input is less than 1', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '0' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(0)
})
it('should ignore non-numeric input and empty input', () => {
render(<CustomizedPagination {...defaultProps} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'abc' } })
expect(input)!.toHaveValue('')
fireEvent.change(input, { target: { value: '' } })
expect(input)!.toHaveValue('')
})
it('should show per page tip on hover and hide on leave', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
fireEvent.mouseEnter(container)
// I18n mock returns ns.key
// I18n mock returns ns.key
expect(screen.getByText('common.pagination.perPage'))!.toBeInTheDocument()
fireEvent.mouseLeave(container)
expect(screen.queryByText('common.pagination.perPage')).not.toBeInTheDocument()
})
it('should call onLimitChange when limit option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
fireEvent.click(screen.getByText('25'))
expect(onLimitChange).toHaveBeenCalledWith(25)
})
it('should call onLimitChange with 10 when 10 option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
const option10 = Array.from(container.children).find(el => el.textContent === '10')!
fireEvent.click(option10)
expect(onLimitChange).toHaveBeenCalledWith(10)
})
it('should call onLimitChange with 50 when 50 option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
fireEvent.click(screen.getByText('50'))
expect(onLimitChange).toHaveBeenCalledWith(50)
})
it('should call onChange when a page button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} total={50} limit={10} onChange={onChange} />)
fireEvent.click(screen.getByText('3'))
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
})
it('should correctly select active limit style for 25 and 50', () => {
// Test limit 25
const { container: containerA } = render(<CustomizedPagination current={0} total={100} limit={25} onChange={vi.fn()} onLimitChange={vi.fn()} />)
const wrapper25 = Array.from(containerA.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '25')!
expect(wrapper25)!.toHaveClass('bg-components-segmented-control-item-active-bg')
// Test limit 50
const { container: containerB } = render(<CustomizedPagination current={0} total={100} limit={50} onChange={vi.fn()} onLimitChange={vi.fn()} />)
const wrapper50 = Array.from(containerB.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '50')!
expect(wrapper50)!.toHaveClass('bg-components-segmented-control-item-active-bg')
})
})
describe('Edge Cases', () => {
it('should handle total of 0', () => {
const { container } = render(<CustomizedPagination {...defaultProps} total={0} />)
expect(container)!.toBeInTheDocument()
})
it('should handle confirm when input value is unchanged (covers false branch of empty string check)', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={4} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
// Blur without changing anything
fireEvent.blur(input)
act(() => {
vi.advanceTimersByTime(500)
})
// onChange should NOT be called
expect(onChange).not.toHaveBeenCalled()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should ignore other keys in handleInputKeyDown (covers false branch of Escape check)', () => {
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'a' })
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should trigger handleInputConfirm with empty string specifically on keydown Enter', async () => {
const { userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '{Enter}')
// Wait for debounce 500ms
await new Promise(r => setTimeout(r, 600))
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should explicitly trigger Escape key logic in handleInputKeyDown', async () => {
const { userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
await user.type(input, '{Escape}')
// Wait for debounce 500ms
await new Promise(r => setTimeout(r, 600))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle single page', () => {
render(<CustomizedPagination {...defaultProps} total={5} limit={10} />)
// totalPages = 1, both buttons should be disabled
const buttons = screen.getAllByRole('button')
expect(buttons[0])!.toBeDisabled()
expect(buttons[buttons.length - 1])!.toBeDisabled()
})
it('should restore input value when blurred with empty value', () => {
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
})

View File

@ -1,549 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { Pagination } from '../pagination'
// Helper to render Pagination with common defaults
function renderPagination({
currentPage = 0,
totalPages = 10,
setCurrentPage = vi.fn(),
edgePageCount = 2,
middlePagesSiblingCount = 1,
truncableText = '...',
truncableClassName = 'truncable',
children,
}: {
currentPage?: number
totalPages?: number
setCurrentPage?: (page: number) => void
edgePageCount?: number
middlePagesSiblingCount?: number
truncableText?: string
truncableClassName?: string
children?: React.ReactNode
} = {}) {
return render(
<Pagination
currentPage={currentPage}
totalPages={totalPages}
setCurrentPage={setCurrentPage}
edgePageCount={edgePageCount}
middlePagesSiblingCount={middlePagesSiblingCount}
truncableText={truncableText}
truncableClassName={truncableClassName}
>
{children}
</Pagination>,
)
}
describe('Pagination', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderPagination()
expect(container).toBeInTheDocument()
})
it('should render children', () => {
renderPagination({ children: <span>child content</span> })
expect(screen.getByText(/child content/i)).toBeInTheDocument()
})
it('should apply className to wrapper div', () => {
const { container } = render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
className="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(container.firstChild).toHaveClass('my-pagination')
})
it('should apply data-testid when provided', () => {
render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
dataTestId="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(screen.getByTestId('my-pagination')).toBeInTheDocument()
})
})
describe('PrevButton', () => {
it('should render prev button', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should call setCurrentPage with previous page when clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText(/prev/i))
expect(setCurrentPage).toHaveBeenCalledWith(2)
})
it('should not navigate below page 0', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText(/prev/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on first page', () => {
renderPagination({
currentPage: 0,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
})
it('should navigate on Enter key press', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(2)
})
it('should not navigate on Enter when disabled', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should render with custom as element', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton as={<div />}>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton dataTestId="prev-btn">Prev</Pagination.PrevButton>,
})
expect(screen.getByTestId('prev-btn')).toBeInTheDocument()
})
})
describe('NextButton', () => {
it('should render next button', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
expect(screen.getByText(/next/i)).toBeInTheDocument()
})
it('should call setCurrentPage with next page when clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText(/next/i))
expect(setCurrentPage).toHaveBeenCalledWith(1)
})
it('should not navigate beyond last page', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 9,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText(/next/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on last page', () => {
renderPagination({
currentPage: 9,
totalPages: 10,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
})
it('should navigate on Enter key press', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(1)
})
it('should not navigate on Enter when disabled', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 9,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton dataTestId="next-btn">Next</Pagination.NextButton>,
})
expect(screen.getByTestId('next-btn')).toBeInTheDocument()
})
})
describe('PageButton', () => {
it('should render page number buttons', () => {
renderPagination({
currentPage: 0,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should apply activeClassName to current page', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// current page is 2, so page 3 (1-indexed) should be active
expect(screen.getByText('3').closest('a')).toHaveClass('active')
})
it('should apply inactiveClassName to non-current pages', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
expect(screen.getByText('1').closest('a')).toHaveClass('inactive')
})
it('should call setCurrentPage when a page button is clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 5,
setCurrentPage,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
fireEvent.click(screen.getByText('3'))
expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed
})
it('should navigate on Enter key press on a page button', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 5,
setCurrentPage,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
fireEvent.keyDown(screen.getByText('4').closest('a')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed
})
it('should render truncable text when pages are truncated', () => {
renderPagination({
currentPage: 5,
totalPages: 20,
edgePageCount: 2,
middlePagesSiblingCount: 1,
truncableText: '...',
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// With 20 pages and current at 5, there should be truncation
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1)
})
})
describe('Edge Cases', () => {
it('should handle single page', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 1,
setCurrentPage,
children: (
<>
<Pagination.PrevButton>Prev</Pagination.PrevButton>
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
<Pagination.NextButton>Next</Pagination.NextButton>
</>
),
})
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle zero total pages', () => {
const { container } = renderPagination({
currentPage: 0,
totalPages: 0,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
expect(container).toBeInTheDocument()
})
it('should cover undefined active/inactive dataTestIds', () => {
// Re-render PageButton without active/inactive data test ids to hit the undefined branch in cn() fallback
renderPagination({
currentPage: 1,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
renderExtraProps={page => ({ 'aria-label': `Page ${page}` })}
/>
),
})
expect(screen.getByText('2')).toHaveAttribute('aria-label', 'Page 2')
})
it('should cover nextPages when edge pages fall perfectly into middle Pages', () => {
renderPagination({
currentPage: 5,
totalPages: 10,
edgePageCount: 8, // Very large edge page count to hit the filter(!middlePages.includes) branches
middlePagesSiblingCount: 1,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should hide truncation element if truncable is false', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
edgePageCount: 1,
middlePagesSiblingCount: 1,
// When we are at page 2, middle pages are [2, 3, 4] (if 0-indexed, wait, currentPage is 0-indexed in hook?)
// Let's just render the component which calls the internal TruncableElement, when previous/next are NOT truncable
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Truncation only happens if middlePages > previousPages.last + 1
expect(screen.queryByText('...')).not.toBeInTheDocument()
})
it('should hit getAllPreviousPages with less than 1 element', () => {
renderPagination({
currentPage: 0,
totalPages: 10,
edgePageCount: 1,
middlePagesSiblingCount: 0,
children: <Pagination.PageButton className="btn" activeClassName="act" inactiveClassName="inact" />,
})
// With currentPage = 0, middlePages = [1], getAllPreviousPages() -> slice(0, 0) -> []
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should fire previous() keyboard event even if it does nothing without crashing', () => {
// Line 38: pagination.currentPage + 1 > 1 check is usually guarded by disabled, but we can verify it explicitly.
const setCurrentPage = vi.fn()
// Use a span so that 'disabled' attribute doesn't prevent fireEvent.click from firing
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton as={<span />}>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText('Prev'))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should fire next() even if it does nothing without crashing', () => {
// Line 73: pagination.currentPage + 1 < pages.length verify
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 10,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton as={<span />}>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText('Next'))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should fall back to undefined when truncableClassName is empty', () => {
// Line 115: `<li className={truncableClassName || undefined}>{truncableText}</li>`
renderPagination({
currentPage: 5,
totalPages: 10,
truncableClassName: '',
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Should not have a class attribute
const truncableElements = screen.getAllByText('...')
expect(truncableElements[0]).not.toHaveAttribute('class')
})
it('should handle dataTestIdActive and dataTestIdInactive completely', () => {
// Lines 137-144
renderPagination({
currentPage: 1, // 0-indexed, so page 2 is active
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdActive="active-test-id"
dataTestIdInactive="inactive-test-id"
/>
),
})
const activeBtn = screen.getByTestId('active-test-id')
expect(activeBtn).toHaveTextContent('2')
const inactiveBtn = screen.getByTestId('inactive-test-id-1') // page 1
expect(inactiveBtn).toHaveTextContent('1')
})
it('should hit getAllNextPages.length < 1 in hook', () => {
renderPagination({
currentPage: 2,
totalPages: 3,
edgePageCount: 1,
middlePagesSiblingCount: 0,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Current is 3 (index 2). middlePages = [3]. getAllNextPages = slice(3, 3) = []
// This will trigger the `getAllNextPages.length < 1` branch
expect(screen.getByText('3')).toBeInTheDocument()
})
it('should handle only dataTestIdInactive without dataTestIdActive', () => {
renderPagination({
currentPage: 1,
totalPages: 3,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdInactive="inactive-test-id"
/>
),
})
// Missing dataTestIdActive branch coverage on line 144
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle only dataTestIdActive without dataTestIdInactive', () => {
renderPagination({
currentPage: 1, // page 2 is active
totalPages: 3,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdActive="active-test-id"
/>
),
})
// This hits the branch where dataTestIdActive exists but not dataTestIdInactive
expect(screen.getByTestId('active-test-id')).toHaveTextContent('2')
expect(screen.queryByTestId('inactive-test-id-1')).not.toBeInTheDocument()
})
})
})

View File

@ -1,94 +0,0 @@
import type { IPaginationProps, IUsePagination } from './type'
import * as React from 'react'
import { useCallback } from 'react'
const usePagination = ({
currentPage,
setCurrentPage,
truncableText = '...',
truncableClassName = '',
totalPages,
edgePageCount,
middlePagesSiblingCount,
}: IPaginationProps): IUsePagination => {
const pages = React.useMemo(() => Array.from({ length: totalPages }, (_, i) => i + 1), [totalPages])
const hasPreviousPage = currentPage > 1
const hasNextPage = currentPage < totalPages
const isReachedToFirst = currentPage <= middlePagesSiblingCount
const isReachedToLast = currentPage + middlePagesSiblingCount >= totalPages
const middlePages = React.useMemo(() => {
const middlePageCount = middlePagesSiblingCount * 2 + 1
if (isReachedToFirst)
return pages.slice(0, middlePageCount)
if (isReachedToLast)
return pages.slice(-middlePageCount)
return pages.slice(
currentPage - middlePagesSiblingCount,
currentPage + middlePagesSiblingCount + 1,
)
}, [currentPage, isReachedToFirst, isReachedToLast, middlePagesSiblingCount, pages])
const getAllPreviousPages = useCallback(() => {
return pages.slice(0, middlePages[0]! - 1)
}, [middlePages, pages])
const previousPages = React.useMemo(() => {
if (isReachedToFirst || getAllPreviousPages().length < 1)
return []
return pages
.slice(0, edgePageCount)
.filter(p => !middlePages.includes(p))
}, [edgePageCount, getAllPreviousPages, isReachedToFirst, middlePages, pages])
const getAllNextPages = React.useMemo(() => {
return pages.slice(
middlePages[middlePages.length - 1],
pages[pages.length],
)
}, [pages, middlePages])
const nextPages = React.useMemo(() => {
if (isReachedToLast)
return []
if (getAllNextPages.length < 1)
return []
return pages
.slice(pages.length - edgePageCount, pages.length)
.filter(p => !middlePages.includes(p))
}, [edgePageCount, getAllNextPages.length, isReachedToLast, middlePages, pages])
const isPreviousTruncable = React.useMemo(() => {
// Is truncable if first value of middlePage is larger than last value of previousPages
return middlePages[0]! > previousPages[previousPages.length - 1]! + 1
}, [previousPages, middlePages])
const isNextTruncable = React.useMemo(() => {
// Is truncable if last value of middlePage is larger than first value of previousPages
return middlePages[middlePages.length - 1]! + 1 < nextPages[0]!
}, [nextPages, middlePages])
return {
currentPage,
setCurrentPage,
truncableText,
truncableClassName,
pages,
hasPreviousPage,
hasNextPage,
previousPages,
isPreviousTruncable,
middlePages,
isNextTruncable,
nextPages,
}
}
export default usePagination

View File

@ -1,81 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
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 tracking-[0.18em] text-text-tertiary uppercase">
<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,
},
}

View File

@ -1,201 +0,0 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { Pagination } from './pagination'
export type Props = {
className?: string
current: number
onChange: (cur: number) => void
total: number
limit?: number
onLimitChange?: (limit: number) => void
}
const CustomizedPagination: FC<Props> = ({
className,
current,
onChange,
total,
limit = 10,
onLimitChange,
}) => {
const { t } = useTranslation()
const totalPages = Math.ceil(total / limit)
const inputRef = React.useRef<HTMLDivElement>(null)
const [showInput, setShowInput] = React.useState(false)
const [inputValue, setInputValue] = React.useState<string | number>(current + 1)
const [showPerPageTip, setShowPerPageTip] = React.useState(false)
const { run: handlePaging } = useDebounceFn((value: string) => {
if (Number.parseInt(value) > totalPages) {
setInputValue(totalPages)
onChange(totalPages - 1)
setShowInput(false)
return
}
if (Number.parseInt(value) < 1) {
setInputValue(1)
onChange(0)
setShowInput(false)
return
}
onChange(Number.parseInt(value) - 1)
setInputValue(Number.parseInt(value))
setShowInput(false)
}, { wait: 500 })
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (!value)
return setInputValue('')
if (isNaN(Number.parseInt(value)))
return setInputValue('')
setInputValue(Number.parseInt(value))
}
const handleInputConfirm = () => {
if (inputValue !== '' && String(inputValue) !== String(current + 1)) {
handlePaging(String(inputValue))
return
}
if (inputValue === '')
setInputValue(current + 1)
setShowInput(false)
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleInputConfirm()
}
else if (e.key === 'Escape') {
e.preventDefault()
setInputValue(current + 1)
setShowInput(false)
}
}
const handleInputBlur = () => {
handleInputConfirm()
}
return (
<Pagination
className={cn('flex w-full items-center px-6 py-3 select-none', className)}
currentPage={current}
edgePageCount={2}
middlePagesSiblingCount={1}
setCurrentPage={onChange}
totalPages={totalPages}
truncableClassName="flex items-center justify-center w-8 px-1 py-2 system-sm-medium text-text-tertiary"
truncableText="..."
>
<div className="flex items-center gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
<Pagination.PrevButton
as={<div></div>}
disabled={current === 0}
>
<Button
variant="secondary"
className="size-7 px-1.5"
disabled={current === 0}
>
<RiArrowLeftLine className="size-4" />
</Button>
</Pagination.PrevButton>
{!showInput && (
<div
ref={inputRef}
className="flex items-center gap-0.5 rounded-lg px-2 py-1.5 hover:cursor-text hover:bg-state-base-hover-alt"
onClick={() => setShowInput(true)}
>
<div className="system-xs-medium text-text-secondary">{current + 1}</div>
<div className="system-xs-medium text-text-quaternary">/</div>
<div className="system-xs-medium text-text-secondary">{totalPages}</div>
</div>
)}
{showInput && (
<Input
styleCss={{
height: '28px',
width: `${inputRef.current?.clientWidth}px`,
}}
placeholder=""
autoFocus
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
/>
)}
<Pagination.NextButton
as={<div></div>}
disabled={current === totalPages - 1}
>
<Button
variant="secondary"
className="size-7 px-1.5"
disabled={current === totalPages - 1}
>
<RiArrowRightLine className="size-4" />
</Button>
</Pagination.NextButton>
</div>
<div className={cn('flex grow list-none items-center justify-center gap-1')}>
<Pagination.PageButton
className="flex min-w-8 cursor-pointer items-center justify-center rounded-lg px-1 py-2 system-sm-medium hover:bg-components-button-ghost-bg-hover"
activeClassName="bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover"
inactiveClassName="text-text-tertiary"
/>
</div>
{onLimitChange && (
<div className="flex shrink-0 items-center gap-2">
<div className="w-[51px] shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary">{showPerPageTip ? t('pagination.perPage', { ns: 'common' }) : ''}</div>
<div
className="flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
onMouseEnter={() => setShowPerPageTip(true)}
onMouseLeave={() => setShowPerPageTip(false)}
>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 10 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(10)}
>
10
</div>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 25 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(25)}
>
25
</div>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 50 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(50)}
>
50
</div>
</div>
</div>
)}
</Pagination>
)
}
export default CustomizedPagination

View File

@ -1,190 +0,0 @@
import type {
ButtonProps,
IPagination,
IPaginationProps,
PageButtonProps,
} from './type'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import usePagination from './hook'
const defaultState: IPagination = {
currentPage: 0,
setCurrentPage: noop,
truncableText: '...',
truncableClassName: '',
pages: [],
hasPreviousPage: false,
hasNextPage: false,
previousPages: [],
isPreviousTruncable: false,
middlePages: [],
isNextTruncable: false,
nextPages: [],
}
const PaginationContext: React.Context<IPagination> = React.createContext<IPagination>(defaultState)
const PrevButton = ({
className,
children,
dataTestId,
as = <button type="button" />,
...buttonProps
}: ButtonProps) => {
const pagination = React.useContext(PaginationContext)
const previous = () => {
if (pagination.currentPage + 1 > 1)
pagination.setCurrentPage(pagination.currentPage - 1)
}
const disabled = pagination.currentPage === 0
return (
<as.type
{...buttonProps}
{...as.props}
className={cn(className, as.props.className)}
onClick={() => previous()}
tabIndex={disabled ? '-1' : 0}
disabled={disabled}
data-testid={dataTestId}
onKeyDown={(event: React.KeyboardEvent) => {
event.preventDefault()
if (event.key === 'Enter' && !disabled)
previous()
}}
>
{as.props.children ?? children}
</as.type>
)
}
const NextButton = ({
className,
children,
dataTestId,
as = <button type="button" />,
...buttonProps
}: ButtonProps) => {
const pagination = React.useContext(PaginationContext)
const next = () => {
if (pagination.currentPage + 1 < pagination.pages.length)
pagination.setCurrentPage(pagination.currentPage + 1)
}
const disabled = pagination.currentPage === pagination.pages.length - 1
return (
<as.type
{...buttonProps}
{...as.props}
className={cn(className, as.props.className)}
onClick={() => next()}
tabIndex={disabled ? '-1' : 0}
disabled={disabled}
data-testid={dataTestId}
onKeyDown={(event: React.KeyboardEvent) => {
event.preventDefault()
if (event.key === 'Enter' && !disabled)
next()
}}
>
{as.props.children ?? children}
</as.type>
)
}
type ITruncableElementProps = {
prev?: boolean
}
const TruncableElement = ({ prev }: ITruncableElementProps) => {
const pagination: IPagination = React.useContext(PaginationContext)
const {
isPreviousTruncable,
isNextTruncable,
truncableText,
truncableClassName,
} = pagination
return ((isPreviousTruncable && prev === true) || (isNextTruncable && !prev))
? (
<li className={truncableClassName || undefined}>{truncableText}</li>
)
: null
}
const PageButton = ({
as = <a />,
className,
dataTestIdActive,
dataTestIdInactive,
activeClassName,
inactiveClassName,
renderExtraProps,
}: PageButtonProps) => {
const pagination: IPagination = React.useContext(PaginationContext)
const renderPageButton = (page: number) => (
<li key={page}>
<as.type
data-testid={
cn({
[`${dataTestIdActive}`]:
dataTestIdActive && pagination.currentPage + 1 === page,
[`${dataTestIdInactive}-${page}`]:
dataTestIdActive && pagination.currentPage + 1 !== page,
}) || undefined
}
tabIndex={0}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === 'Enter')
pagination.setCurrentPage(page - 1)
}}
onClick={() => pagination.setCurrentPage(page - 1)}
className={cn(
className,
pagination.currentPage + 1 === page
? activeClassName
: inactiveClassName,
)}
{...as.props}
{...(renderExtraProps ? renderExtraProps(page) : {})}
>
{page}
</as.type>
</li>
)
return (
<>
{pagination.previousPages.map(renderPageButton)}
<TruncableElement prev />
{pagination.middlePages.map(renderPageButton)}
<TruncableElement />
{pagination.nextPages.map(renderPageButton)}
</>
)
}
export const Pagination = ({
dataTestId,
...paginationProps
}: IPaginationProps & { dataTestId?: string }) => {
const pagination = usePagination(paginationProps)
return (
<PaginationContext.Provider value={pagination}>
<div className={paginationProps.className} data-testid={dataTestId}>
{paginationProps.children}
</div>
</PaginationContext.Provider>
)
}
Pagination.PrevButton = PrevButton
Pagination.NextButton = NextButton
Pagination.PageButton = PageButton

View File

@ -1,64 +0,0 @@
import type { ButtonHTMLAttributes } from 'react'
type ElementProps = {
className?: string
children?: React.ReactNode
[key: string]: unknown
}
type IBasePaginationProps = {
currentPage: number
setCurrentPage: (page: number) => void
truncableText?: string
truncableClassName?: string
}
type IPaginationProps = IBasePaginationProps & {
totalPages: number
edgePageCount: number
middlePagesSiblingCount: number
className?: string
children?: React.ReactNode
}
type IUsePagination = IBasePaginationProps & {
pages: number[]
hasPreviousPage: boolean
hasNextPage: boolean
previousPages: number[]
isPreviousTruncable: boolean
middlePages: number[]
isNextTruncable: boolean
nextPages: number[]
}
type IPagination = IUsePagination & {
setCurrentPage: (page: number) => void
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
as?: React.ReactElement<ElementProps>
children?: string | React.ReactNode
className?: string
dataTestId?: string
}
type PageButtonProps = ButtonProps & {
/**
* Provide a custom ReactElement (e.g. Next/Link)
*/
as?: React.ReactElement<ElementProps>
activeClassName?: string
inactiveClassName?: string
dataTestIdActive?: string
dataTestIdInactive?: string
renderExtraProps?: (pageNum: number) => {}
}
export type {
ButtonProps,
IPagination,
IPaginationProps,
IUsePagination,
PageButtonProps,
}

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
@ -9,6 +8,14 @@ import DocumentList from '../../list'
const mockPush = vi.fn()
type PaginationProps = {
current: number
onChange: (page: number) => void
total: number
limit?: number
onLimitChange?: (limit: number) => void
}
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,

View File

@ -1,12 +1,11 @@
'use client'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
@ -19,6 +18,15 @@ import RenameModal from './rename-modal'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type PaginationProps = {
className?: string
current: number
total: number
limit?: number
onChange: (page: number) => void
onLimitChange?: (limit: number) => void
}
type DocumentListProps = {
embeddingAvailable: boolean
documents: LocalDoc[]
@ -48,6 +56,8 @@ const DocumentList = ({
onSortChange,
}: DocumentListProps) => {
const { t } = useTranslation()
const pageSize = pagination.limit ?? 10
const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1)
const datasetConfig = useDatasetDetailContext(s => s.dataset)
const chunkingMode = datasetConfig?.doc_form
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
@ -198,8 +208,25 @@ const DocumentList = ({
{!!pagination.total && (
<Pagination
{...pagination}
className="w-full shrink-0"
className="shrink-0"
page={pagination.current + 1}
totalPages={totalPages}
onPageChange={page => pagination.onChange(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={pagination.onLimitChange
? {
value: pageSize,
options: [10, 25, 50],
onValueChange: pagination.onLimitChange,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}
: undefined}
/>
)}

View File

@ -203,18 +203,22 @@ vi.mock('@/app/components/base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('@/app/components/base/pagination', () => ({
default: ({ current, total, onChange, onLimitChange }: {
current: number
total: number
onChange: (page: number) => void
onLimitChange: (limit: number) => void
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({ page, totalPages, onPageChange, pageSize }: {
page: number
totalPages: number
onPageChange: (page: number) => void
pageSize?: {
onValueChange: (limit: number) => void
}
}) => (
<div data-testid="pagination">
<span data-testid="current-page">{current}</span>
<span data-testid="total-items">{total}</span>
<button data-testid="next-page" onClick={() => onChange(current + 1)}>Next</button>
<button data-testid="change-limit" onClick={() => onLimitChange(20)}>Change Limit</button>
<span data-testid="current-page">{page - 1}</span>
<span data-testid="total-pages">{totalPages}</span>
<button data-testid="next-page" onClick={() => onPageChange(page + 1)}>Next</button>
{pageSize && (
<button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}>Change Limit</button>
)}
</div>
),
}))
@ -1180,15 +1184,14 @@ describe('Inline callback and hook initialization coverage', () => {
})
})
// Covers paginationTotal in full-doc mode
it('should compute pagination total from child chunk data in full-doc mode', () => {
it('should compute pagination pages from child chunk data in full-doc mode', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'full-doc'
mockChildSegmentListData.total = 42
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('total-items'))!.toHaveTextContent('42')
expect(screen.getByTestId('total-pages'))!.toHaveTextContent('5')
})
// Covers search input change

View File

@ -3,10 +3,10 @@ import type { FC } from 'react'
import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination'
import {
useChunkListAllKey,
useChunkListDisabledKey,
@ -142,10 +142,11 @@ const Completed: FC<ICompletedProps> = ({
return childSegmentDataHook.childChunkListData?.total || 0
return segmentListDataHook.segmentListData?.total || 0
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
const totalPages = Math.max(Math.ceil(paginationTotal / limit), 1)
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page + 1)
setCurrentPage(page)
}, [])
// Context value
@ -225,12 +226,22 @@ const Completed: FC<ICompletedProps> = ({
{/* Pagination */}
<Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" />
<Pagination
current={currentPage - 1}
onChange={handlePageChange}
total={paginationTotal}
limit={limit}
onLimitChange={setLimit}
className={segmentListDataHook.isFullDocMode ? 'px-3' : ''}
page={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
{/* Drawer Group - only render when docForm is available */}

View File

@ -18,6 +18,7 @@ import {
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
@ -25,7 +26,6 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import FloatRightContainer from '@/app/components/base/float-right-container'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
import DatasetDetailContext from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -63,6 +63,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 })
const total = recordsRes?.total || 0
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const { dataset: currentDataset } = useContext(DatasetDetailContext)
const isExternal = currentDataset?.provider === 'external'
@ -151,7 +152,19 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
<>
<Records records={recordsRes?.data} onClickRecord={handleClickRecord} />
{(total && total > limit)
? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
? (
<Pagination
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
/>
)
: null}
</>
)}

View File

@ -1,4 +1,4 @@
import type { AnchorHTMLAttributes, ReactElement } from 'react'
import type { ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
@ -55,7 +55,7 @@ vi.mock('@/context/workspace-context-provider', () => ({
}))
vi.mock('@/next/link', () => ({
default: ({ children, href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }) => <a href={href} {...props}>{children}</a>,
default: ({ children, href }: { children?: React.ReactNode, href?: string }) => <a href={href}>{children}</a>,
}))
let mockIsWorkspaceEditor = false
@ -122,9 +122,7 @@ describe('Header', () => {
it('should render header with main nav components', () => {
renderHeader()
expect(screen.getByRole('link', { name: 'Dify' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
expect(screen.getByTestId('account-dropdown')).toBeInTheDocument()
@ -168,7 +166,7 @@ describe('Header', () => {
mockMedia = 'mobile'
renderHeader()
expect(screen.getByRole('link', { name: 'Dify' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
})
@ -179,8 +177,8 @@ describe('Header', () => {
renderHeader()
expect(screen.getByRole('link', { name: 'Acme Workspace' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('img', { name: /logo/i })).not.toBeInTheDocument()
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
})
@ -191,18 +189,18 @@ describe('Header', () => {
renderHeader()
expect(screen.getByRole('link', { name: 'Custom Title' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
})
it('should use default Dify link label when branding enabled but no application_title', () => {
it('should show default Dify text when branding enabled but no application_title', () => {
mockBrandingEnabled = true
mockBrandingTitle = null
mockBrandingLogo = null
renderHeader()
expect(screen.getByRole('link', { name: 'Dify' })).toHaveAttribute('href', '/apps')
expect(screen.getByText('Dify')).toBeInTheDocument()
})
it('should show dataset nav for editor who is not dataset operator', () => {

View File

@ -44,23 +44,21 @@ const Header = () => {
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
const logoLabel = isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
const renderLogo = () => (
<Link
href="/apps"
className="flex h-8 shrink-0 items-center justify-center overflow-hidden rounded-sm px-0.5 hover:opacity-80 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={logoLabel}
>
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt=""
/>
)
: <DifyLogo alt="" />}
</Link>
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
if (isMobile) {

View File

@ -194,6 +194,7 @@
"imageInput.browse": "تصفح",
"imageInput.dropImageHere": "أسقط صورتك هنا، أو",
"imageInput.supportedFormats": "يدعم PNG و JPG و JPEG و WEBP و GIF",
"imageUploader.imageList": "قائمة الصور",
"imageUploader.imageUpload": "تحميل الصورة",
"imageUploader.pasteImageLink": "لصق رابط الصورة",
"imageUploader.pasteImageLinkInputPlaceholder": "لصق رابط الصورة هنا",
@ -512,6 +513,8 @@
"operation.ok": "موافق",
"operation.openInNewTab": "فتح في علامة تبويب جديدة",
"operation.params": "معلمات",
"operation.pause": "إيقاف مؤقت",
"operation.play": "تشغيل",
"operation.refresh": "إعادة تشغيل",
"operation.regenerate": "إعادة إنشاء",
"operation.reload": "إعادة تحميل",
@ -519,6 +522,7 @@
"operation.rename": "إعادة تسمية",
"operation.reset": "إعادة تعيين",
"operation.resetKeywords": "إعادة تعيين الكلمات الرئيسية",
"operation.retry": "إعادة المحاولة",
"operation.save": "حفظ",
"operation.saveAndEnable": "حفظ وتمكين",
"operation.saveAndRegenerate": "حفظ وإعادة إنشاء القطع الفرعية",
@ -533,13 +537,19 @@
"operation.skip": "تخطي",
"operation.submit": "إرسال",
"operation.sure": "أنا متأكد",
"operation.toggleFullscreen": "تبديل ملء الشاشة",
"operation.toggleMute": "تبديل كتم الصوت",
"operation.view": "عرض",
"operation.viewDetails": "عرض التفاصيل",
"operation.viewMore": "عرض المزيد",
"operation.yes": "نعم",
"operation.zoomIn": "تكبير",
"operation.zoomOut": "تصغير",
"pagination.editPageNumber": "تعديل رقم الصفحة، الصفحة الحالية {{page}} من {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "عناصر لكل صفحة",
"pagination.previous": "Previous page",
"placeholder.input": "يرجى الإدخال",
"placeholder.search": "بحث...",
"placeholder.select": "يرجى التحديد",
@ -677,5 +687,6 @@
"voiceInput.converting": "التحويل إلى نص...",
"voiceInput.notAllow": "الميكروفون غير مصرح به",
"voiceInput.speaking": "تحدث الآن...",
"voiceInput.start": "إدخال صوتي",
"you": "أنت"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "blättern",
"imageInput.dropImageHere": "Laden Sie Ihr Bild hierher hoch oder",
"imageInput.supportedFormats": "Unterstützt PNG, JPG, JPEG, WEBP und GIF",
"imageUploader.imageList": "Bilderliste",
"imageUploader.imageUpload": "Bild-Upload",
"imageUploader.pasteImageLink": "Bildlink einfügen",
"imageUploader.pasteImageLinkInputPlaceholder": "Bildlink hier einfügen",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "In neuem Tab öffnen",
"operation.params": "Parameter",
"operation.pause": "Pausieren",
"operation.play": "Abspielen",
"operation.refresh": "Neustart",
"operation.regenerate": "Erneuern",
"operation.reload": "Neu laden",
@ -519,6 +522,7 @@
"operation.rename": "Umbenennen",
"operation.reset": "Zurücksetzen",
"operation.resetKeywords": "Schlüsselwörter zurücksetzen",
"operation.retry": "Erneut versuchen",
"operation.save": "Speichern",
"operation.saveAndEnable": "Speichern und Aktivieren",
"operation.saveAndRegenerate": "Speichern und Regenerieren von untergeordneten Chunks",
@ -533,13 +537,19 @@
"operation.skip": "Schiff",
"operation.submit": "Senden",
"operation.sure": "Ich bin sicher",
"operation.toggleFullscreen": "Vollbild umschalten",
"operation.toggleMute": "Stummschaltung umschalten",
"operation.view": "Ansehen",
"operation.viewDetails": "Details anzeigen",
"operation.viewMore": "MEHR SEHEN",
"operation.yes": "Ja",
"operation.zoomIn": "Vergrößern",
"operation.zoomOut": "Verkleinern",
"pagination.editPageNumber": "Seitennummer bearbeiten, aktuelle Seite {{page}} von {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Artikel pro Seite",
"pagination.previous": "Previous page",
"placeholder.input": "Bitte eingeben",
"placeholder.search": "Suchen...",
"placeholder.select": "Bitte auswählen",
@ -677,5 +687,6 @@
"voiceInput.converting": "Umwandlung in Text...",
"voiceInput.notAllow": "Mikrofon nicht autorisiert",
"voiceInput.speaking": "Sprechen Sie jetzt...",
"voiceInput.start": "Spracheingabe",
"you": "Du"
}

View File

@ -545,7 +545,11 @@
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pagination.editPageNumber": "Edit page number, current page {{page}} of {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Items per page",
"pagination.previous": "Previous page",
"placeholder.input": "Please enter",
"placeholder.search": "Search...",
"placeholder.select": "Please select",

View File

@ -194,6 +194,7 @@
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Deja tu imagen aquí, o",
"imageInput.supportedFormats": "Soporta PNG, JPG, JPEG, WEBP y GIF",
"imageUploader.imageList": "Lista de imágenes",
"imageUploader.imageUpload": "Carga de Imagen",
"imageUploader.pasteImageLink": "Pegar enlace de imagen",
"imageUploader.pasteImageLinkInputPlaceholder": "Pega el enlace de imagen aquí",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Abrir en una nueva pestaña",
"operation.params": "Parámetros",
"operation.pause": "Pausar",
"operation.play": "Reproducir",
"operation.refresh": "Reiniciar",
"operation.regenerate": "Regenerar",
"operation.reload": "Recargar",
@ -519,6 +522,7 @@
"operation.rename": "Renombrar",
"operation.reset": "Restablecer",
"operation.resetKeywords": "Restablecer palabras clave",
"operation.retry": "Reintentar",
"operation.save": "Guardar",
"operation.saveAndEnable": "Guardar y habilitar",
"operation.saveAndRegenerate": "Guardar y regenerar fragmentos secundarios",
@ -533,13 +537,19 @@
"operation.skip": "Navío",
"operation.submit": "Enviar",
"operation.sure": "Estoy seguro",
"operation.toggleFullscreen": "Alternar pantalla completa",
"operation.toggleMute": "Alternar silencio",
"operation.view": "Vista",
"operation.viewDetails": "Ver detalles",
"operation.viewMore": "VER MÁS",
"operation.yes": "Sí",
"operation.zoomIn": "Acercar",
"operation.zoomOut": "Alejar",
"pagination.editPageNumber": "Editar número de página, página actual {{page}} de {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Elementos por página",
"pagination.previous": "Previous page",
"placeholder.input": "Por favor ingresa",
"placeholder.search": "Buscar...",
"placeholder.select": "Por favor selecciona",
@ -677,5 +687,6 @@
"voiceInput.converting": "Convirtiendo a texto...",
"voiceInput.notAllow": "micrófono no autorizado",
"voiceInput.speaking": "Habla ahora...",
"voiceInput.start": "Entrada de voz",
"you": "Tú"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "مرورگر",
"imageInput.dropImageHere": "عکس خود را اینجا رها کنید، یا",
"imageInput.supportedFormats": "از فرمت‌های PNG، JPG، JPEG، WEBP و GIF پشتیبانی می‌کند",
"imageUploader.imageList": "فهرست تصاویر",
"imageUploader.imageUpload": "بارگذاری تصویر",
"imageUploader.pasteImageLink": "پیوند تصویر را بچسبانید",
"imageUploader.pasteImageLinkInputPlaceholder": "پیوند تصویر را اینجا بچسبانید",
@ -512,6 +513,8 @@
"operation.ok": "تایید",
"operation.openInNewTab": "باز کردن در برگه جدید",
"operation.params": "پارامترها",
"operation.pause": "مکث",
"operation.play": "پخش",
"operation.refresh": "شروع مجدد",
"operation.regenerate": "بازسازی",
"operation.reload": "بارگذاری مجدد",
@ -519,6 +522,7 @@
"operation.rename": "تغییر نام",
"operation.reset": "بازنشانی",
"operation.resetKeywords": "بازنشانی کلمات کلیدی",
"operation.retry": "تلاش دوباره",
"operation.save": "ذخیره",
"operation.saveAndEnable": "ذخیره و فعال سازی",
"operation.saveAndRegenerate": "ذخیره و بازسازی تکه های فرزند",
@ -533,13 +537,19 @@
"operation.skip": "کشتی",
"operation.submit": "ارسال",
"operation.sure": "مطمئن هستم",
"operation.toggleFullscreen": "تغییر حالت تمام‌صفحه",
"operation.toggleMute": "تغییر حالت بی‌صدا",
"operation.view": "مشاهده",
"operation.viewDetails": "دیدن جزئیات",
"operation.viewMore": "بیشتر ببینید",
"operation.yes": "بله",
"operation.zoomIn": "بزرگنمایی",
"operation.zoomOut": "کوچک نمایی",
"pagination.editPageNumber": "ویرایش شماره صفحه، صفحه فعلی {{page}} از {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "موارد در هر صفحه",
"pagination.previous": "Previous page",
"placeholder.input": "لطفا وارد کنید",
"placeholder.search": "جستجو...",
"placeholder.select": "لطفا انتخاب کنید",
@ -677,5 +687,6 @@
"voiceInput.converting": "در حال تبدیل به متن...",
"voiceInput.notAllow": "میکروفون مجاز نیست",
"voiceInput.speaking": "اکنون صحبت کنید...",
"voiceInput.start": "ورودی صوتی",
"you": "تو"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "naviguer",
"imageInput.dropImageHere": "Déposez votre image ici, ou",
"imageInput.supportedFormats": "Prend en charge PNG, JPG, JPEG, WEBP et GIF",
"imageUploader.imageList": "Liste des images",
"imageUploader.imageUpload": "Téléchargement d'image",
"imageUploader.pasteImageLink": "Collez le lien de l'image",
"imageUploader.pasteImageLinkInputPlaceholder": "Collez le lien de l'image ici",
@ -512,6 +513,8 @@
"operation.ok": "D'accord",
"operation.openInNewTab": "Ouvrir dans un nouvel onglet",
"operation.params": "Paramètres",
"operation.pause": "Pause",
"operation.play": "Lire",
"operation.refresh": "Redémarrer",
"operation.regenerate": "Régénérer",
"operation.reload": "Recharger",
@ -519,6 +522,7 @@
"operation.rename": "Renommer",
"operation.reset": "Réinitialiser",
"operation.resetKeywords": "Réinitialiser les mots-clés",
"operation.retry": "Réessayer",
"operation.save": "Enregistrer",
"operation.saveAndEnable": "Enregistrer et Activer",
"operation.saveAndRegenerate": "Enregistrer et régénérer des morceaux enfants",
@ -533,13 +537,19 @@
"operation.skip": "Bateau",
"operation.submit": "Envoyer",
"operation.sure": "Je suis sûr",
"operation.toggleFullscreen": "Basculer en plein écran",
"operation.toggleMute": "Activer/désactiver le son",
"operation.view": "Vue",
"operation.viewDetails": "Voir les détails",
"operation.viewMore": "VOIR PLUS",
"operation.yes": "Oui",
"operation.zoomIn": "Zoom avant",
"operation.zoomOut": "Zoom arrière",
"pagination.editPageNumber": "Modifier le numéro de page, page actuelle {{page}} sur {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articles par page",
"pagination.previous": "Previous page",
"placeholder.input": "Veuillez entrer",
"placeholder.search": "Rechercher...",
"placeholder.select": "Veuillez sélectionner",
@ -677,5 +687,6 @@
"voiceInput.converting": "Conversion en texte...",
"voiceInput.notAllow": "microphone non autorisé",
"voiceInput.speaking": "Parle maintenant...",
"voiceInput.start": "Saisie vocale",
"you": "Vous"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ब्राउज़ करें",
"imageInput.dropImageHere": "अपनी छवि यहाँ छोड़ें, या",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP और GIF का समर्थन करता है",
"imageUploader.imageList": "छवि सूची",
"imageUploader.imageUpload": "छवि अपलोड",
"imageUploader.pasteImageLink": "छवि लिंक पेस्ट करें",
"imageUploader.pasteImageLinkInputPlaceholder": "छवि लिंक यहाँ पेस्ट करें",
@ -512,6 +513,8 @@
"operation.ok": "ठीक है",
"operation.openInNewTab": "नए टैब में खोलें",
"operation.params": "पैरामीटर",
"operation.pause": "रोकें",
"operation.play": "चलाएं",
"operation.refresh": "पुनः प्रारंभ करें",
"operation.regenerate": "पुनर्जन्म",
"operation.reload": "पुनः लोड करें",
@ -519,6 +522,7 @@
"operation.rename": "नाम बदलें",
"operation.reset": "रीसेट करें",
"operation.resetKeywords": "कीवर्ड रीसेट करें",
"operation.retry": "पुनः प्रयास करें",
"operation.save": "सहेजें",
"operation.saveAndEnable": "सहेजें और सक्षम करें",
"operation.saveAndRegenerate": "सहेजें और पुन: उत्पन्न करें बाल विखंडू",
@ -533,13 +537,19 @@
"operation.skip": "जहाज़",
"operation.submit": "जमा करें",
"operation.sure": "मुझे यकीन है",
"operation.toggleFullscreen": "फ़ुलस्क्रीन टॉगल करें",
"operation.toggleMute": "म्यूट टॉगल करें",
"operation.view": "देखना",
"operation.viewDetails": "विवरण देखें",
"operation.viewMore": "और देखें",
"operation.yes": "हाँ",
"operation.zoomIn": "ज़ूम इन करें",
"operation.zoomOut": "ज़ूम आउट करें",
"pagination.editPageNumber": "पृष्ठ संख्या संपादित करें, वर्तमान पृष्ठ {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "प्रति पृष्ठ आइटम",
"pagination.previous": "Previous page",
"placeholder.input": "कृपया दर्ज करें",
"placeholder.search": "खोजें...",
"placeholder.select": "कृपया चयन करें",
@ -677,5 +687,6 @@
"voiceInput.converting": "पाठ में परिवर्तित हो रहा है...",
"voiceInput.notAllow": "माइक्रोफोन अधिकृत नहीं है",
"voiceInput.speaking": "अब बोलें...",
"voiceInput.start": "वॉइस इनपुट",
"you": "आप"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "Telusuri",
"imageInput.dropImageHere": "Letakkan gambar Anda di sini, atau",
"imageInput.supportedFormats": "Mendukung PNG, JPG, JPEG, WEBP dan GIF",
"imageUploader.imageList": "Daftar gambar",
"imageUploader.imageUpload": "Unggah Gambar",
"imageUploader.pasteImageLink": "Tempel tautan gambar",
"imageUploader.pasteImageLinkInputPlaceholder": "Tempel tautan gambar di sini",
@ -512,6 +513,8 @@
"operation.ok": "OKE",
"operation.openInNewTab": "Buka di tab baru",
"operation.params": "Parameter",
"operation.pause": "Jeda",
"operation.play": "Putar",
"operation.refresh": "Segarkan",
"operation.regenerate": "Regenerasi",
"operation.reload": "Muat Ulang",
@ -519,6 +522,7 @@
"operation.rename": "Ubah nama",
"operation.reset": "Reset",
"operation.resetKeywords": "Atur ulang kata kunci",
"operation.retry": "Coba lagi",
"operation.save": "Simpan",
"operation.saveAndEnable": "Simpan & Aktifkan",
"operation.saveAndRegenerate": "Simpan & Buat Ulang Potongan Anak",
@ -533,13 +537,19 @@
"operation.skip": "Lewat",
"operation.submit": "Kirim",
"operation.sure": "Saya yakin",
"operation.toggleFullscreen": "Alihkan layar penuh",
"operation.toggleMute": "Alihkan bisu",
"operation.view": "Lihat",
"operation.viewDetails": "Lihat Detail",
"operation.viewMore": "LIHAT LEBIH BANYAK",
"operation.yes": "Ya",
"operation.zoomIn": "Perbesar",
"operation.zoomOut": "Perkecil",
"pagination.editPageNumber": "Edit nomor halaman, halaman saat ini {{page}} dari {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Item per halaman",
"pagination.previous": "Previous page",
"placeholder.input": "Silakan masuk",
"placeholder.search": "Cari...",
"placeholder.select": "Silakan pilih",
@ -677,5 +687,6 @@
"voiceInput.converting": "Mengonversi ke teks...",
"voiceInput.notAllow": "mikrofon tidak diizinkan",
"voiceInput.speaking": "Bicaralah sekarang...",
"voiceInput.start": "Input suara",
"you": "Kamu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "sfogliare",
"imageInput.dropImageHere": "Trascina la tua immagine qui, oppure",
"imageInput.supportedFormats": "Supporta PNG, JPG, JPEG, WEBP e GIF",
"imageUploader.imageList": "Elenco immagini",
"imageUploader.imageUpload": "Caricamento Immagine",
"imageUploader.pasteImageLink": "Incolla link immagine",
"imageUploader.pasteImageLinkInputPlaceholder": "Incolla qui il link immagine",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Apri in una nuova scheda",
"operation.params": "Parametri",
"operation.pause": "Pausa",
"operation.play": "Riproduci",
"operation.refresh": "Riavvia",
"operation.regenerate": "Rigenerare",
"operation.reload": "Ricarica",
@ -519,6 +522,7 @@
"operation.rename": "Rinomina",
"operation.reset": "Reimposta",
"operation.resetKeywords": "Reimposta parole chiave",
"operation.retry": "Riprova",
"operation.save": "Salva",
"operation.saveAndEnable": "Salva & Abilita",
"operation.saveAndRegenerate": "Salva e rigenera i blocchi figlio",
@ -533,13 +537,19 @@
"operation.skip": "Nave",
"operation.submit": "Invia",
"operation.sure": "Sono sicuro",
"operation.toggleFullscreen": "Attiva/disattiva schermo intero",
"operation.toggleMute": "Attiva/disattiva muto",
"operation.view": "Vista",
"operation.viewDetails": "Visualizza dettagli",
"operation.viewMore": "SCOPRI DI PIÙ",
"operation.yes": "Sì",
"operation.zoomIn": "Ingrandisci",
"operation.zoomOut": "Zoom indietro",
"pagination.editPageNumber": "Modifica numero pagina, pagina corrente {{page}} di {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articoli per pagina",
"pagination.previous": "Previous page",
"placeholder.input": "Per favore inserisci",
"placeholder.search": "Cerca...",
"placeholder.select": "Per favore seleziona",
@ -677,5 +687,6 @@
"voiceInput.converting": "Conversione in testo...",
"voiceInput.notAllow": "microfono non autorizzato",
"voiceInput.speaking": "Parla ora...",
"voiceInput.start": "Input vocale",
"you": "Tu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ブラウズする",
"imageInput.dropImageHere": "ここに画像をドロップするか、",
"imageInput.supportedFormats": "PNG、JPG、JPEG、WEBP、および GIF をサポートしています。",
"imageUploader.imageList": "画像リスト",
"imageUploader.imageUpload": "画像アップロード",
"imageUploader.pasteImageLink": "画像リンクを貼り付ける",
"imageUploader.pasteImageLinkInputPlaceholder": "ここに画像リンクを貼り付けてください",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "新しいタブで開く",
"operation.params": "パラメータ",
"operation.pause": "一時停止",
"operation.play": "再生",
"operation.refresh": "リフレッシュ",
"operation.regenerate": "再生成",
"operation.reload": "再読み込み",
@ -519,6 +522,7 @@
"operation.rename": "名前の変更",
"operation.reset": "リセット",
"operation.resetKeywords": "キーワードをリセット",
"operation.retry": "再試行",
"operation.save": "保存",
"operation.saveAndEnable": "保存 & 有効に",
"operation.saveAndRegenerate": "保存して子チャンクを再生成",
@ -533,13 +537,19 @@
"operation.skip": "スキップ",
"operation.submit": "送信",
"operation.sure": "確認済み",
"operation.toggleFullscreen": "全画面表示を切り替え",
"operation.toggleMute": "ミュートを切り替え",
"operation.view": "表示",
"operation.viewDetails": "詳細を見る",
"operation.viewMore": "さらに表示",
"operation.yes": "はい",
"operation.zoomIn": "ズームインする",
"operation.zoomOut": "ズームアウト",
"pagination.editPageNumber": "ページ番号を編集、現在のページ {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "ページあたりのアイテム数",
"pagination.previous": "Previous page",
"placeholder.input": "入力してください",
"placeholder.search": "検索...",
"placeholder.select": "選択してください",
@ -677,5 +687,6 @@
"voiceInput.converting": "テキストに変換中...",
"voiceInput.notAllow": "マイクが許可されていません",
"voiceInput.speaking": "今話しています...",
"voiceInput.start": "音声入力",
"you": "あなた"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "찾아보기",
"imageInput.dropImageHere": "여기에 이미지를 드롭하거나",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP 및 GIF 를 지원합니다.",
"imageUploader.imageList": "이미지 목록",
"imageUploader.imageUpload": "이미지 업로드",
"imageUploader.pasteImageLink": "이미지 링크 붙여넣기",
"imageUploader.pasteImageLinkInputPlaceholder": "여기에 이미지 링크를 붙여넣으세요",
@ -512,6 +513,8 @@
"operation.ok": "확인",
"operation.openInNewTab": "새 탭에서 열기",
"operation.params": "매개변수",
"operation.pause": "일시 중지",
"operation.play": "재생",
"operation.refresh": "새로 고침",
"operation.regenerate": "재생성",
"operation.reload": "다시 불러오기",
@ -519,6 +522,7 @@
"operation.rename": "이름 바꾸기",
"operation.reset": "초기화",
"operation.resetKeywords": "키워드 재설정",
"operation.retry": "다시 시도",
"operation.save": "저장",
"operation.saveAndEnable": "저장 및 활성화",
"operation.saveAndRegenerate": "저장 및 자식 청크 재생성",
@ -533,13 +537,19 @@
"operation.skip": "건너뛰기",
"operation.submit": "전송",
"operation.sure": "확인",
"operation.toggleFullscreen": "전체 화면 전환",
"operation.toggleMute": "음소거 전환",
"operation.view": "보기",
"operation.viewDetails": "세부 정보보기",
"operation.viewMore": "더보기",
"operation.yes": "네",
"operation.zoomIn": "확대",
"operation.zoomOut": "축소",
"pagination.editPageNumber": "페이지 번호 편집, 현재 {{page}} / {{totalPages}} 페이지",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "페이지당 항목 수",
"pagination.previous": "Previous page",
"placeholder.input": "입력해주세요",
"placeholder.search": "검색...",
"placeholder.select": "선택해주세요",
@ -677,5 +687,6 @@
"voiceInput.converting": "텍스트로 변환 중...",
"voiceInput.notAllow": "마이크가 허용되지 않았습니다",
"voiceInput.speaking": "지금 말하고 있습니다...",
"voiceInput.start": "음성 입력",
"you": "나"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "browse",
"imageInput.dropImageHere": "Drop your image here, or",
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",
"imageUploader.imageList": "Afbeeldingenlijst",
"imageUploader.imageUpload": "Image Upload",
"imageUploader.pasteImageLink": "Paste image link",
"imageUploader.pasteImageLinkInputPlaceholder": "Paste image link here",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Open in new tab",
"operation.params": "Params",
"operation.pause": "Pauzeren",
"operation.play": "Afspelen",
"operation.refresh": "Restart",
"operation.regenerate": "Regenerate",
"operation.reload": "Reload",
@ -519,6 +522,7 @@
"operation.rename": "Rename",
"operation.reset": "Reset",
"operation.resetKeywords": "Reset keywords",
"operation.retry": "Opnieuw proberen",
"operation.save": "Save",
"operation.saveAndEnable": "Save & Enable",
"operation.saveAndRegenerate": "Save & Regenerate Child Chunks",
@ -533,13 +537,19 @@
"operation.skip": "Skip",
"operation.submit": "Submit",
"operation.sure": "I'm sure",
"operation.toggleFullscreen": "Volledig scherm schakelen",
"operation.toggleMute": "Dempen schakelen",
"operation.view": "View",
"operation.viewDetails": "View Details",
"operation.viewMore": "VIEW MORE",
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pagination.editPageNumber": "Paginanummer bewerken, huidige pagina {{page}} van {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Items per page",
"pagination.previous": "Previous page",
"placeholder.input": "Please enter",
"placeholder.search": "Search...",
"placeholder.select": "Please select",
@ -677,5 +687,6 @@
"voiceInput.converting": "Converting to text...",
"voiceInput.notAllow": "microphone not authorized",
"voiceInput.speaking": "Speak now...",
"voiceInput.start": "Spraakinvoer",
"you": "You"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "przeglądaj",
"imageInput.dropImageHere": "Upuść swój obraz tutaj, lub",
"imageInput.supportedFormats": "Obsługuje PNG, JPG, JPEG, WEBP i GIF",
"imageUploader.imageList": "Lista obrazów",
"imageUploader.imageUpload": "Przesyłanie obrazu",
"imageUploader.pasteImageLink": "Wklej link do obrazu",
"imageUploader.pasteImageLinkInputPlaceholder": "Wklej tutaj link do obrazu",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Otwórz w nowej karcie",
"operation.params": "Parametry",
"operation.pause": "Pauza",
"operation.play": "Odtwórz",
"operation.refresh": "Odśwież",
"operation.regenerate": "Ponownie wygenerować",
"operation.reload": "Przeładuj",
@ -519,6 +522,7 @@
"operation.rename": "Zmień nazwę",
"operation.reset": "Resetuj",
"operation.resetKeywords": "Resetuj słowa kluczowe",
"operation.retry": "Spróbuj ponownie",
"operation.save": "Zapisz",
"operation.saveAndEnable": "Zapisz i Włącz",
"operation.saveAndRegenerate": "Zapisywanie i regeneracja fragmentów podrzędnych",
@ -533,13 +537,19 @@
"operation.skip": "Statek",
"operation.submit": "Prześlij",
"operation.sure": "Jestem pewien",
"operation.toggleFullscreen": "Przełącz pełny ekran",
"operation.toggleMute": "Przełącz wyciszenie",
"operation.view": "Widok",
"operation.viewDetails": "Wyświetl szczegóły",
"operation.viewMore": "ZOBACZ WIĘCEJ",
"operation.yes": "Tak",
"operation.zoomIn": "Powiększenie",
"operation.zoomOut": "Pomniejszanie",
"pagination.editPageNumber": "Edytuj numer strony, bieżąca strona {{page}} z {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Ilość elementów na stronie",
"pagination.previous": "Previous page",
"placeholder.input": "Proszę wprowadzić",
"placeholder.search": "Szukaj...",
"placeholder.select": "Proszę wybrać",
@ -677,5 +687,6 @@
"voiceInput.converting": "Konwertowanie na tekst...",
"voiceInput.notAllow": "mikrofon nieautoryzowany",
"voiceInput.speaking": "Mów teraz...",
"voiceInput.start": "Wprowadzanie głosowe",
"you": "Ty"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Arraste sua imagem aqui, ou",
"imageInput.supportedFormats": "Suporta PNG, JPG, JPEG, WEBP e GIF",
"imageUploader.imageList": "Lista de imagens",
"imageUploader.imageUpload": "Enviar Imagem",
"imageUploader.pasteImageLink": "Colar link da imagem",
"imageUploader.pasteImageLinkInputPlaceholder": "Cole o link da imagem aqui",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Abrir em nova guia",
"operation.params": "Parâmetros",
"operation.pause": "Pausar",
"operation.play": "Reproduzir",
"operation.refresh": "Reiniciar",
"operation.regenerate": "Regenerar",
"operation.reload": "Recarregar",
@ -519,6 +522,7 @@
"operation.rename": "Renomear",
"operation.reset": "Redefinir",
"operation.resetKeywords": "Redefinir palavras-chave",
"operation.retry": "Tentar novamente",
"operation.save": "Salvar",
"operation.saveAndEnable": "Salvar e Ativar",
"operation.saveAndRegenerate": "Salvar e regenerar pedaços filhos",
@ -533,13 +537,19 @@
"operation.skip": "Navio",
"operation.submit": "Enviar",
"operation.sure": "Tenho certeza",
"operation.toggleFullscreen": "Alternar tela cheia",
"operation.toggleMute": "Alternar mudo",
"operation.view": "Vista",
"operation.viewDetails": "Ver detalhes",
"operation.viewMore": "VER MAIS",
"operation.yes": "Sim",
"operation.zoomIn": "Ampliar",
"operation.zoomOut": "Diminuir o zoom",
"pagination.editPageNumber": "Editar número da página, página atual {{page}} de {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Itens por página",
"pagination.previous": "Previous page",
"placeholder.input": "Por favor, insira",
"placeholder.search": "Pesquisar...",
"placeholder.select": "Por favor, selecione",
@ -677,5 +687,6 @@
"voiceInput.converting": "Convertendo para texto...",
"voiceInput.notAllow": "microfone não autorizado",
"voiceInput.speaking": "Fale agora...",
"voiceInput.start": "Entrada por voz",
"you": "Você"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "naviga",
"imageInput.dropImageHere": "Trageți imaginea aici sau",
"imageInput.supportedFormats": "Suportă PNG, JPG, JPEG, WEBP și GIF",
"imageUploader.imageList": "Listă de imagini",
"imageUploader.imageUpload": "Încărcare imagine",
"imageUploader.pasteImageLink": "Inserați link-ul imaginii",
"imageUploader.pasteImageLinkInputPlaceholder": "Inserați link-ul imaginii aici",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Deschide într-o filă nouă",
"operation.params": "Parametri",
"operation.pause": "Pauză",
"operation.play": "Redare",
"operation.refresh": "Reîncarcă",
"operation.regenerate": "Regenera",
"operation.reload": "Reîncarcă",
@ -519,6 +522,7 @@
"operation.rename": "Redenumește",
"operation.reset": "Resetează",
"operation.resetKeywords": "Resetează cuvintele cheie",
"operation.retry": "Reîncercați",
"operation.save": "Salvează",
"operation.saveAndEnable": "Salvează și Activează",
"operation.saveAndRegenerate": "Salvați și regenerați bucățile secundare",
@ -533,13 +537,19 @@
"operation.skip": "Navă",
"operation.submit": "Prezinte",
"operation.sure": "Sunt sigur",
"operation.toggleFullscreen": "Comută ecran complet",
"operation.toggleMute": "Comută sunetul",
"operation.view": "Vedere",
"operation.viewDetails": "Vezi detalii",
"operation.viewMore": "VEZI MAI MULT",
"operation.yes": "Da",
"operation.zoomIn": "Măriți",
"operation.zoomOut": "Micșorare",
"pagination.editPageNumber": "Editați numărul paginii, pagina curentă {{page}} din {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articole pe pagină",
"pagination.previous": "Previous page",
"placeholder.input": "Vă rugăm să introduceți",
"placeholder.search": "Caută...",
"placeholder.select": "Vă rugăm să selectați",
@ -677,5 +687,6 @@
"voiceInput.converting": "Se convertește la text...",
"voiceInput.notAllow": "microfonul nu este autorizat",
"voiceInput.speaking": "Vorbiți acum...",
"voiceInput.start": "Introducere vocală",
"you": "Tu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "просмотр",
"imageInput.dropImageHere": "Перетащите ваше изображение сюда или",
"imageInput.supportedFormats": "Поддерживает PNG, JPG, JPEG, WEBP и GIF",
"imageUploader.imageList": "Список изображений",
"imageUploader.imageUpload": "Загрузка изображения",
"imageUploader.pasteImageLink": "Вставить ссылку на изображение",
"imageUploader.pasteImageLinkInputPlaceholder": "Вставьте ссылку на изображение здесь",
@ -512,6 +513,8 @@
"operation.ok": "ОК",
"operation.openInNewTab": "Открыть в новой вкладке",
"operation.params": "Параметры",
"operation.pause": "Пауза",
"operation.play": "Воспроизвести",
"operation.refresh": "Перезапустить",
"operation.regenerate": "Регенерировать",
"operation.reload": "Перезагрузить",
@ -519,6 +522,7 @@
"operation.rename": "Переименовать",
"operation.reset": "Сбросить",
"operation.resetKeywords": "Сбросить ключевые слова",
"operation.retry": "Повторить",
"operation.save": "Сохранить",
"operation.saveAndEnable": "Сохранить и включить",
"operation.saveAndRegenerate": "Сохранение и повторное создание дочерних блоков",
@ -533,13 +537,19 @@
"operation.skip": "Корабль",
"operation.submit": "Отправить",
"operation.sure": "Я уверен",
"operation.toggleFullscreen": "Переключить полноэкранный режим",
"operation.toggleMute": "Переключить звук",
"operation.view": "Вид",
"operation.viewDetails": "Подробнее",
"operation.viewMore": "ПОДРОБНЕЕ",
"operation.yes": "Да",
"operation.zoomIn": "Увеличить",
"operation.zoomOut": "Уменьшение масштаба",
"pagination.editPageNumber": "Изменить номер страницы, текущая страница {{page}} из {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Элементов на странице",
"pagination.previous": "Previous page",
"placeholder.input": "Пожалуйста, введите",
"placeholder.search": "Поиск...",
"placeholder.select": "Пожалуйста, выберите",
@ -677,5 +687,6 @@
"voiceInput.converting": "Преобразование в текст...",
"voiceInput.notAllow": "микрофон не авторизован",
"voiceInput.speaking": "Говорите сейчас...",
"voiceInput.start": "Голосовой ввод",
"you": "Ты"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "brskati",
"imageInput.dropImageHere": "Tukaj spustite svojo sliko ali",
"imageInput.supportedFormats": "Podpira PNG, JPG, JPEG, WEBP in GIF",
"imageUploader.imageList": "Seznam slik",
"imageUploader.imageUpload": "Nalaganje slik",
"imageUploader.pasteImageLink": "Prilepi povezavo do slike",
"imageUploader.pasteImageLinkInputPlaceholder": "Tukaj prilepi povezavo do slike",
@ -512,6 +513,8 @@
"operation.ok": "V redu",
"operation.openInNewTab": "Odpri v novem zavihku",
"operation.params": "Parametri",
"operation.pause": "Premor",
"operation.play": "Predvajaj",
"operation.refresh": "Osveži",
"operation.regenerate": "Regeneracijo",
"operation.reload": "Ponovno naloži",
@ -519,6 +522,7 @@
"operation.rename": "Preimenuj",
"operation.reset": "Ponastavi",
"operation.resetKeywords": "Ponastavi ključne besede",
"operation.retry": "Poskusi znova",
"operation.save": "Shrani",
"operation.saveAndEnable": "Shrani in omogoči",
"operation.saveAndRegenerate": "Shranite in regenerirajte otroške koščke",
@ -533,13 +537,19 @@
"operation.skip": "Ladja",
"operation.submit": "Predložiti",
"operation.sure": "Prepričan sem",
"operation.toggleFullscreen": "Preklopi celozaslonski način",
"operation.toggleMute": "Preklopi utišanje",
"operation.view": "Pogled",
"operation.viewDetails": "Poglej podrobnosti",
"operation.viewMore": "POGLEJ VEČ",
"operation.yes": "Da",
"operation.zoomIn": "Povečava",
"operation.zoomOut": "Pomanjšanje",
"pagination.editPageNumber": "Uredi številko strani, trenutna stran {{page}} od {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Elementi na stran",
"pagination.previous": "Previous page",
"placeholder.input": "Vnesite prosim",
"placeholder.search": "Išči...",
"placeholder.select": "Izberite prosim",
@ -677,5 +687,6 @@
"voiceInput.converting": "Pretvorba v besedilo ...",
"voiceInput.notAllow": "Mikrofon ni pooblaščen",
"voiceInput.speaking": "Spregovorite zdaj ...",
"voiceInput.start": "Glasovni vnos",
"you": "Ti"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ท่องเว็บ",
"imageInput.dropImageHere": "วางภาพของคุณที่นี่ หรือ",
"imageInput.supportedFormats": "รองรับ PNG, JPG, JPEG, WEBP และ GIF",
"imageUploader.imageList": "รายการรูปภาพ",
"imageUploader.imageUpload": "อัปโหลดรูปภาพ",
"imageUploader.pasteImageLink": "วางลิงก์รูปภาพ",
"imageUploader.pasteImageLinkInputPlaceholder": "วางลิงค์รูปภาพที่นี่",
@ -512,6 +513,8 @@
"operation.ok": "ตกลง, ได้",
"operation.openInNewTab": "เปิดในแท็บใหม่",
"operation.params": "พารามิเตอร์",
"operation.pause": "หยุดชั่วคราว",
"operation.play": "เล่น",
"operation.refresh": "เริ่มใหม่",
"operation.regenerate": "สร้างใหม่",
"operation.reload": "โหลด",
@ -519,6 +522,7 @@
"operation.rename": "ตั้งชื่อใหม่",
"operation.reset": "รี เซ็ต",
"operation.resetKeywords": "รีเซ็ตคำสำคัญ",
"operation.retry": "ลองอีกครั้ง",
"operation.save": "ประหยัด",
"operation.saveAndEnable": "บันทึกและเปิดใช้งาน",
"operation.saveAndRegenerate": "บันทึกและสร้างก้อนย่อยใหม่",
@ -533,13 +537,19 @@
"operation.skip": "เรือ",
"operation.submit": "ส่ง",
"operation.sure": "ฉันแน่ใจ",
"operation.toggleFullscreen": "สลับเต็มหน้าจอ",
"operation.toggleMute": "สลับปิดเสียง",
"operation.view": "ทิวทัศน์",
"operation.viewDetails": "ดูรายละเอียด",
"operation.viewMore": "ดูเพิ่มเติม",
"operation.yes": "ใช่",
"operation.zoomIn": "ซูมเข้า",
"operation.zoomOut": "ซูมออก",
"pagination.editPageNumber": "แก้ไขหมายเลขหน้า หน้าปัจจุบัน {{page}} จาก {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "รายการต่อหน้า",
"pagination.previous": "Previous page",
"placeholder.input": "กรุณากรอก",
"placeholder.search": "ค้นหา...",
"placeholder.select": "กรุณาเลือก",
@ -677,5 +687,6 @@
"voiceInput.converting": "กําลังแปลงเป็นข้อความ...",
"voiceInput.notAllow": "ไม่ได้รับอนุญาตไมโครโฟน",
"voiceInput.speaking": "พูดเดี๋ยวนี้...",
"voiceInput.start": "ป้อนข้อมูลด้วยเสียง",
"you": "คุณ"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "göz atın",
"imageInput.dropImageHere": "Görüntünüzü buraya bırakın veya",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP ve GIF'i destekler",
"imageUploader.imageList": "Görsel listesi",
"imageUploader.imageUpload": "Görüntü Yükleme",
"imageUploader.pasteImageLink": "Görüntü bağlantısını yapıştır",
"imageUploader.pasteImageLinkInputPlaceholder": "Görüntü bağlantısını buraya yapıştırın",
@ -512,6 +513,8 @@
"operation.ok": "Tamam",
"operation.openInNewTab": "Yeni sekmede aç",
"operation.params": "Parametreler",
"operation.pause": "Duraklat",
"operation.play": "Oynat",
"operation.refresh": "Yeniden Başlat",
"operation.regenerate": "Yeniden Oluştur",
"operation.reload": "Yeniden Yükle",
@ -519,6 +522,7 @@
"operation.rename": "Yeniden Adlandır",
"operation.reset": "Sıfırla",
"operation.resetKeywords": "Anahtar kelimeleri sıfırla",
"operation.retry": "Tekrar dene",
"operation.save": "Kaydet",
"operation.saveAndEnable": "Kaydet ve Etkinleştir",
"operation.saveAndRegenerate": "Alt Parçaları Kaydetme ve Yeniden Oluşturma",
@ -533,13 +537,19 @@
"operation.skip": "Atla",
"operation.submit": "Gönder",
"operation.sure": "Eminim",
"operation.toggleFullscreen": "Tam ekranı aç/kapat",
"operation.toggleMute": "Sessize al/aç",
"operation.view": "Görüntüle",
"operation.viewDetails": "Detayları Görüntüle",
"operation.viewMore": "DAHA FAZLA GÖSTER",
"operation.yes": "Evet",
"operation.zoomIn": "Yakınlaştırma",
"operation.zoomOut": "Uzaklaştırma",
"pagination.editPageNumber": "Sayfa numarasını düzenle, geçerli sayfa {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Sayfa başına öğe sayısı",
"pagination.previous": "Previous page",
"placeholder.input": "Lütfen girin",
"placeholder.search": "Ara...",
"placeholder.select": "Lütfen seçin",
@ -677,5 +687,6 @@
"voiceInput.converting": "Metne dönüştürülüyor...",
"voiceInput.notAllow": "mikrofon yetkilendirilmedi",
"voiceInput.speaking": "Şimdi konuş...",
"voiceInput.start": "Sesli giriş",
"you": "Sen"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "перегляд",
"imageInput.dropImageHere": "Перетягніть зображення сюди або",
"imageInput.supportedFormats": "Підтримує PNG, JPG, JPEG, WEBP і GIF",
"imageUploader.imageList": "Список зображень",
"imageUploader.imageUpload": "Завантаження зображення",
"imageUploader.pasteImageLink": "Вставити посилання на зображення",
"imageUploader.pasteImageLinkInputPlaceholder": "Вставте посилання на зображення тут",
@ -512,6 +513,8 @@
"operation.ok": "ОК",
"operation.openInNewTab": "Відкрити в новій вкладці",
"operation.params": "Параметри",
"operation.pause": "Пауза",
"operation.play": "Відтворити",
"operation.refresh": "Перезапустити",
"operation.regenerate": "Відновити",
"operation.reload": "Перезавантажити",
@ -519,6 +522,7 @@
"operation.rename": "Перейменувати",
"operation.reset": "Скинути",
"operation.resetKeywords": "Скинути ключові слова",
"operation.retry": "Повторити",
"operation.save": "Зберегти",
"operation.saveAndEnable": "Зберегти та Увімкнути",
"operation.saveAndRegenerate": "Збереження та регенерація дочірніх фрагментів",
@ -533,13 +537,19 @@
"operation.skip": "Корабель",
"operation.submit": "Представити",
"operation.sure": "Я впевнений",
"operation.toggleFullscreen": "Перемкнути повноекранний режим",
"operation.toggleMute": "Перемкнути звук",
"operation.view": "Вид",
"operation.viewDetails": "Перегляд докладних відомостей",
"operation.viewMore": "ДИВИТИСЬ БІЛЬШЕ",
"operation.yes": "Так",
"operation.zoomIn": "Збільшити масштаб",
"operation.zoomOut": "Зменшити масштаб",
"pagination.editPageNumber": "Редагувати номер сторінки, поточна сторінка {{page}} з {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Елементів на сторінці",
"pagination.previous": "Previous page",
"placeholder.input": "Будь ласка, введіть текст",
"placeholder.search": "Пошук...",
"placeholder.select": "Будь ласка, оберіть параметр",
@ -677,5 +687,6 @@
"voiceInput.converting": "Перетворення на текст...",
"voiceInput.notAllow": "мікрофон не авторизований",
"voiceInput.speaking": "Говоріть зараз...",
"voiceInput.start": "Голосове введення",
"you": "Ти"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "duyệt",
"imageInput.dropImageHere": "Kéo hình ảnh của bạn vào đây, hoặc",
"imageInput.supportedFormats": "Hỗ trợ PNG, JPG, JPEG, WEBP và GIF",
"imageUploader.imageList": "Danh sách hình ảnh",
"imageUploader.imageUpload": "Tải ảnh lên",
"imageUploader.pasteImageLink": "Dán liên kết ảnh",
"imageUploader.pasteImageLinkInputPlaceholder": "Dán liên kết ảnh ở đây",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Mở trong tab mới",
"operation.params": "Tham số",
"operation.pause": "Tạm dừng",
"operation.play": "Phát",
"operation.refresh": "Làm mới",
"operation.regenerate": "Tái tạo",
"operation.reload": "Tải lại",
@ -519,6 +522,7 @@
"operation.rename": "Đổi tên",
"operation.reset": "Đặt lại",
"operation.resetKeywords": "Đặt lại từ khóa",
"operation.retry": "Thử lại",
"operation.save": "Lưu",
"operation.saveAndEnable": "Lưu & Kích hoạt",
"operation.saveAndRegenerate": "Lưu và tạo lại các phần con",
@ -533,13 +537,19 @@
"operation.skip": "Tàu",
"operation.submit": "Trình",
"operation.sure": "Tôi chắc chắn",
"operation.toggleFullscreen": "Chuyển đổi toàn màn hình",
"operation.toggleMute": "Bật/tắt tiếng",
"operation.view": "Cảnh",
"operation.viewDetails": "Xem chi tiết",
"operation.viewMore": "XEM THÊM",
"operation.yes": "Vâng",
"operation.zoomIn": "Phóng to",
"operation.zoomOut": "Thu nhỏ",
"pagination.editPageNumber": "Chỉnh sửa số trang, trang hiện tại {{page}} trên {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Mục trên mỗi trang",
"pagination.previous": "Previous page",
"placeholder.input": "Vui lòng nhập",
"placeholder.search": "Tìm kiếm...",
"placeholder.select": "Vui lòng chọn",
@ -677,5 +687,6 @@
"voiceInput.converting": "Chuyển đổi thành văn bản...",
"voiceInput.notAllow": "micro không được ủy quyền",
"voiceInput.speaking": "Hãy nói...",
"voiceInput.start": "Nhập bằng giọng nói",
"you": "Bạn"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "浏览",
"imageInput.dropImageHere": "将图片拖放到此处,或",
"imageInput.supportedFormats": "支持 PNG、JPG、JPEG、WEBP 和 GIF 格式",
"imageUploader.imageList": "图片列表",
"imageUploader.imageUpload": "图片上传",
"imageUploader.pasteImageLink": "粘贴图片链接",
"imageUploader.pasteImageLinkInputPlaceholder": "将图像链接粘贴到此处",
@ -512,6 +513,8 @@
"operation.ok": "好的",
"operation.openInNewTab": "在新标签页打开",
"operation.params": "参数设置",
"operation.pause": "暂停",
"operation.play": "播放",
"operation.refresh": "重新开始",
"operation.regenerate": "重新生成",
"operation.reload": "刷新",
@ -519,6 +522,7 @@
"operation.rename": "重命名",
"operation.reset": "重置",
"operation.resetKeywords": "重置关键词",
"operation.retry": "重试",
"operation.save": "保存",
"operation.saveAndEnable": "保存并启用",
"operation.saveAndRegenerate": "保存并重新生成子分段",
@ -533,13 +537,19 @@
"operation.skip": "跳过",
"operation.submit": "提交",
"operation.sure": "我确定",
"operation.toggleFullscreen": "切换全屏",
"operation.toggleMute": "切换静音",
"operation.view": "查看",
"operation.viewDetails": "查看详情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "缩小",
"pagination.editPageNumber": "编辑页码,当前第 {{page}} 页,共 {{totalPages}} 页",
"pagination.next": "下一页",
"pagination.pageNumber": "页码",
"pagination.perPage": "每页显示",
"pagination.previous": "上一页",
"placeholder.input": "请输入",
"placeholder.search": "搜索...",
"placeholder.select": "请选择",
@ -677,5 +687,6 @@
"voiceInput.converting": "正在转换为文本...",
"voiceInput.notAllow": "麦克风未授权",
"voiceInput.speaking": "现在讲...",
"voiceInput.start": "语音输入",
"you": "你"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "瀏覽",
"imageInput.dropImageHere": "將您的圖片放在這裡,或",
"imageInput.supportedFormats": "支援 PNG、JPG、JPEG、WEBP 和 GIF",
"imageUploader.imageList": "圖片列表",
"imageUploader.imageUpload": "圖片上傳",
"imageUploader.pasteImageLink": "貼上圖片連結",
"imageUploader.pasteImageLinkInputPlaceholder": "將影象連結貼上到此處",
@ -512,6 +513,8 @@
"operation.ok": "好的",
"operation.openInNewTab": "在新選項卡中打開",
"operation.params": "引數設定",
"operation.pause": "暫停",
"operation.play": "播放",
"operation.refresh": "重新開始",
"operation.regenerate": "再生",
"operation.reload": "重新整理",
@ -519,6 +522,7 @@
"operation.rename": "重新命名",
"operation.reset": "重置",
"operation.resetKeywords": "重置關鍵字",
"operation.retry": "重試",
"operation.save": "儲存",
"operation.saveAndEnable": "儲存並啟用",
"operation.saveAndRegenerate": "保存並重新生成子塊",
@ -533,13 +537,19 @@
"operation.skip": "船",
"operation.submit": "提交",
"operation.sure": "我確定",
"operation.toggleFullscreen": "切換全螢幕",
"operation.toggleMute": "切換靜音",
"operation.view": "視圖",
"operation.viewDetails": "查看詳情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "縮小",
"pagination.editPageNumber": "編輯頁碼,目前第 {{page}} 頁,共 {{totalPages}} 頁",
"pagination.next": "下一頁",
"pagination.pageNumber": "頁碼",
"pagination.perPage": "每頁項目數",
"pagination.previous": "上一頁",
"placeholder.input": "請輸入",
"placeholder.search": "搜尋...",
"placeholder.select": "請選擇",
@ -677,5 +687,6 @@
"voiceInput.converting": "正在轉換為文字...",
"voiceInput.notAllow": "麥克風未授權",
"voiceInput.speaking": "現在講...",
"voiceInput.start": "語音輸入",
"you": "你"
}