refactor: use path instead of query

This commit is contained in:
Stephen Zhou 2026-02-12 14:57:43 +08:00
parent 0db446b8ea
commit 2d3e244a1f
No known key found for this signature in database
18 changed files with 74 additions and 80 deletions

View File

@ -1,6 +1,7 @@
import type { SearchTab } from './search-params'
import type { PluginsSort, SearchParamsFromCollection } from './types'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { CATEGORY_ALL, DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
@ -32,24 +33,57 @@ export function useSearchText() {
return useQueryState('q', marketplaceSearchParamsParsers.q)
}
export function useActivePluginCategory() {
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
return [getValidatedPluginCategory(category), setCategory] as const
const router = useRouter()
const pathname = usePathname()
const segments = pathname.split('/').filter(Boolean)
const categoryFromPath = segments[1] || CATEGORY_ALL
const validatedCategory = getValidatedPluginCategory(categoryFromPath)
const handleChange = (newCategory: string) => {
const newPathSegments = [...segments]
newPathSegments[1] = newCategory
const newPath = `/${newPathSegments.join('/')}`
router.push(newPath)
}
return [validatedCategory, handleChange] as const
}
export function useActiveTemplateCategory() {
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
return [getValidatedTemplateCategory(category), setCategory] as const
const router = useRouter()
const pathname = usePathname()
const segments = pathname.split('/').filter(Boolean)
const categoryFromPath = segments[1] || CATEGORY_ALL
const validatedCategory = getValidatedTemplateCategory(categoryFromPath)
const handleChange = (newCategory: string) => {
router.push(`/${CREATION_TYPE.templates}/${newCategory}`)
}
return [validatedCategory, handleChange] as const
}
export function useFilterPluginTags() {
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
}
export function useSearchTab() {
return useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
const router = useRouter()
// /search/[searchTab]
const { searchTab } = useParams()
const handleChange = useCallback(
(newTab: string) => {
const location = new URL(window.location.href)
location.pathname = `/search/${newTab}`
router.push(location.href)
},
[router],
)
return [searchTab, handleChange] as const
}
export function useCreationType() {
return useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
const pathname = usePathname()
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === CREATION_TYPE.templates)
return CREATION_TYPE.templates
return CREATION_TYPE.plugins
}
// Search-page-specific filter hooks (separate from list-page category/tags)
@ -77,7 +111,7 @@ export function useSearchFilterTags() {
export const searchModeAtom = atom<true | null>(null)
export function useMarketplaceSearchMode() {
const [creationType] = useCreationType()
const creationType = useCreationType()
const [searchText] = useSearchText()
const [searchTab] = useSearchTab()
const [filterPluginTags] = useFilterPluginTags()
@ -98,7 +132,7 @@ export function useMarketplaceSearchMode() {
* Plugins use `marketplacePluginSortAtom`, templates use `marketplaceTemplateSortAtom`.
*/
export function useActiveSort(): [PluginsSort, (sort: PluginsSort) => void] {
const [creationType] = useCreationType()
const creationType = useCreationType()
const [pluginSort, setPluginSort] = useAtom(marketplacePluginSortAtom)
const [templateSort, setTemplateSort] = useAtom(marketplaceTemplateSortAtom)
const isTemplates = creationType === CREATION_TYPE.templates
@ -112,7 +146,7 @@ export function useActiveSort(): [PluginsSort, (sort: PluginsSort) => void] {
}
export function useActiveSortValue(): PluginsSort {
const [creationType] = useCreationType()
const creationType = useCreationType()
const pluginSort = useAtomValue(marketplacePluginSortAtom)
const templateSort = useAtomValue(marketplaceTemplateSortAtom)
return creationType === CREATION_TYPE.templates ? templateSort : pluginSort

View File

@ -30,7 +30,7 @@ export const Description = ({
marketplaceNav,
}: DescriptionProps) => {
const { t } = useTranslation('plugin')
const [creationType] = useCreationType()
const creationType = useCreationType()
const isTemplatesView = creationType === CREATION_TYPE.templates
const heroTitleKey = isTemplatesView ? 'marketplace.templatesHeroTitle' : 'marketplace.pluginsHeroTitle'
const heroSubtitleKey = isTemplatesView ? 'marketplace.templatesHeroSubtitle' : 'marketplace.pluginsHeroSubtitle'

View File

@ -1,54 +1,19 @@
import type { SearchParams } from 'nuqs'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { createLoader } from 'nuqs/server'
import { getQueryClientServer } from '@/context/query-client-server'
import { marketplaceQuery } from '@/service/client'
import { getValidatedPluginCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceTemplateCollectionsAndTemplates } from './utils'
import { HydrationBoundary } from '@tanstack/react-query'
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
async function getDehydratedState(searchParams?: Promise<SearchParams>) {
if (!searchParams) {
return
}
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
const params = await loadSearchParams(searchParams)
const queryClient = getQueryClientServer()
if (params.creationType === CREATION_TYPE.templates) {
await queryClient.prefetchQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }),
queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(),
})
return dehydrate(queryClient)
}
const pluginCategory = getValidatedPluginCategory(params.category)
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)) {
return
}
const collectionsParams = getCollectionsParams(pluginCategory)
await queryClient.prefetchQuery({
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams),
})
return dehydrate(queryClient)
}
export async function HydrateQueryClient({
// eslint-disable-next-line unused-imports/no-unused-vars
searchParams,
children,
}: {
searchParams: Promise<SearchParams> | undefined
children: React.ReactNode
}) {
const dehydratedState = await getDehydratedState(searchParams)
// TODO: bring back dehydrated state
return (
<HydrationBoundary state={dehydratedState}>
<HydrationBoundary state={null}>
{children}
</HydrationBoundary>
)

View File

@ -530,7 +530,7 @@ describe('utils', () => {
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
const link = getPluginLinkInMarketplace(plugin)
expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin')
expect(link).toBe('https://marketplace.dify.ai/plugin/test-org/test-plugin')
})
it('should return correct link for bundle', () => {
@ -546,7 +546,7 @@ describe('utils', () => {
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
const link = getPluginDetailLinkInMarketplace(plugin)
expect(link).toBe('/plugins/test-org/test-plugin')
expect(link).toBe('/plugin/test-org/test-plugin')
})
it('should return correct detail link for bundle', () => {

View File

@ -120,9 +120,9 @@ vi.mock('../utils', async (importOriginal) => {
return {
...actual,
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
`/plugins/${plugin.org}/${plugin.name}`,
`/plugin/${plugin.org}/${plugin.name}`,
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
`/plugins/${plugin.org}/${plugin.name}`,
`/plugin/${plugin.org}/${plugin.name}`,
}
})
@ -1166,7 +1166,7 @@ describe('CardWrapper (via List integration)', () => {
)
const detailLink = screen.getByText('Detail').closest('a')
expect(detailLink).toHaveAttribute('href', '/plugins/test-org/link-test-plugin')
expect(detailLink).toHaveAttribute('href', '/plugin/test-org/link-test-plugin')
expect(detailLink).toHaveAttribute('target', '_blank')
})

View File

@ -15,7 +15,7 @@ import { CREATION_TYPE } from '../search-params'
import SortDropdown from '../sort-dropdown'
const ListTopInfo = () => {
const [creationType] = useCreationType()
const creationType = useCreationType()
const { t } = useTranslation()
const [filterPluginTags] = useFilterPluginTags()
const [activePluginCategory] = useActivePluginCategory()

View File

@ -34,7 +34,7 @@ const TemplateCardComponent = ({
const iconUrl = getTemplateIconUrl(template)
const handleClick = useCallback(() => {
const url = getMarketplaceUrl(`/templates/${publisher_handle}/${template_name}`, {
const url = getMarketplaceUrl(`/template/${publisher_handle}/${template_name}`, {
theme,
language: locale,
templateId: id,

View File

@ -4,6 +4,7 @@ import type { UnifiedSearchParams } from '../types'
import { useTranslation } from '#i18n'
import { useDebounce } from 'ahooks'
import { useSetAtom } from 'jotai'
import { useRouter } from 'next/navigation'
import { useMemo, useState } from 'react'
import Input from '@/app/components/base/input'
import {
@ -14,7 +15,6 @@ import {
import { cn } from '@/utils/classnames'
import {
searchModeAtom,
useSearchTab,
useSearchText,
} from '../atoms'
import { useMarketplaceUnifiedSearch } from '../query'
@ -31,7 +31,6 @@ const SearchBoxWrapper = ({
}: SearchBoxWrapperProps) => {
const { t } = useTranslation()
const [searchText, handleSearchTextChange] = useSearchText()
const [, setSearchTab] = useSearchTab()
const setSearchMode = useSetAtom(searchModeAtom)
const committedSearch = searchText || ''
const [draftSearch, setDraftSearch] = useState(committedSearch)
@ -39,6 +38,7 @@ const SearchBoxWrapper = ({
const [isHoveringDropdown, setIsHoveringDropdown] = useState(false)
const debouncedDraft = useDebounce(draftSearch, { wait: 300 })
const hasDraft = !!debouncedDraft.trim()
const router = useRouter()
const dropdownQueryParams = useMemo((): UnifiedSearchParams | undefined => {
if (!hasDraft)
@ -68,9 +68,7 @@ const SearchBoxWrapper = ({
const trimmed = draftSearch.trim()
if (!trimmed)
return
handleSearchTextChange(trimmed)
setSearchTab('all')
setSearchMode(true)
router.push(`/search/all/?q=${encodeURIComponent(trimmed)}`)
setIsFocused(false)
}

View File

@ -180,7 +180,7 @@ function TemplatesSection({ templates, t }: {
return (
<DropdownItem
key={template.id}
href={getMarketplaceUrl(`/templates/${template.publisher_handle}/${template.template_name}`, { templateId: template.id })}
href={getMarketplaceUrl(`/template/${template.publisher_handle}/${template.template_name}`, { templateId: template.id })}
icon={(
<div className="flex shrink-0 items-start py-1">
<AppIcon

View File

@ -1,4 +1,4 @@
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
import { parseAsArrayOf, parseAsString } from 'nuqs/server'
export const CREATION_TYPE = {
plugins: 'plugins',
@ -6,18 +6,15 @@ export const CREATION_TYPE = {
} as const
export type CreationType = typeof CREATION_TYPE[keyof typeof CREATION_TYPE]
export const SEARCH_TABS = ['all', 'plugins', 'templates', 'creators'] as const
export type SearchTab = (typeof SEARCH_TABS)[number] | ''
export const marketplaceSearchParamsParsers = {
category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
creationType: parseAsStringEnum<CreationType>([CREATION_TYPE.plugins, CREATION_TYPE.templates]).withDefault(CREATION_TYPE.plugins).withOptions({ history: 'replace' }),
searchTab: parseAsStringEnum<SearchTab>(['all', 'plugins', 'templates', 'creators']).withDefault('').withOptions({ history: 'replace' }),
// Search-page-specific filters (independent from list-page category/tags)
searchCategories: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
searchLanguages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
searchType: parseAsString.withDefault('all').withOptions({ history: 'replace' }),
searchTags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
}
export type SearchTab = 'all' | 'plugins' | 'templates' | 'creators' | ''

View File

@ -29,7 +29,7 @@ const TEMPLATE_SORT_OPTIONS = [
const SortDropdown = () => {
const { t } = useTranslation()
const [creationType] = useCreationType()
const creationType = useCreationType()
const isTemplates = creationType === CREATION_TYPE.templates
const rawOptions = isTemplates ? TEMPLATE_SORT_OPTIONS : PLUGIN_SORT_OPTIONS

View File

@ -134,7 +134,7 @@ export function isPluginsData(data: MarketplaceData): data is PluginsMarketplace
* Returns either plugins or templates data based on URL parameter
*/
export function useMarketplaceData(): MarketplaceData {
const [creationType] = useCreationType()
const creationType = useCreationType()
const pluginsData = usePluginsMarketplaceData(creationType === CREATION_TYPE.plugins)
const templatesData = useTemplatesMarketplaceData(creationType === CREATION_TYPE.templates)

View File

@ -89,13 +89,13 @@ export const getFormattedPlugin = (bundle: Plugin): Plugin => {
export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record<string, string | undefined>) => {
if (plugin.type === 'bundle')
return getMarketplaceUrl(`/bundles/${plugin.org}/${plugin.name}`, params)
return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params)
return getMarketplaceUrl(`/plugin/${plugin.org}/${plugin.name}`, params)
}
export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `/bundles/${plugin.org}/${plugin.name}`
return `/plugins/${plugin.org}/${plugin.name}`
return `/plugin/${plugin.org}/${plugin.name}`
}
export const getMarketplacePluginsByCollectionId = async (

View File

@ -65,7 +65,7 @@ const getDetailUrl = (
return `https://github.com/${repo}`
}
if (source === PluginSource.marketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
return getMarketplaceUrl(`/plugin/${author}/${name}`, { language: locale, theme })
return ''
}
@ -249,7 +249,7 @@ const DetailHeader = ({
status={status}
deprecatedReason={deprecated_reason}
alternativePluginId={alternative_plugin_id}
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
alternativePluginURL={getMarketplaceUrl(`/plugin/${alternative_plugin_id}`, { language: currentLocale, theme })}
className="mt-3"
/>
)}

View File

@ -195,7 +195,7 @@ const PluginItem: FC<Props> = ({
{source === PluginSource.marketplace && enable_marketplace
&& (
<>
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
<a href={getMarketplaceUrl(`/plugin/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
<div className="system-2xs-medium-uppercase text-text-tertiary">
{t('from', { ns: 'plugin' })}
{' '}

View File

@ -105,12 +105,12 @@ export const SubmitRequestDropdown = () => {
export const CreationTypeTabs = () => {
const { t } = useTranslation()
const [creationType] = useCreationType()
const creationType = useCreationType()
return (
<div className="flex items-center gap-1">
<Link
href={`/?creationType=${CREATION_TYPE.plugins}`}
href={`/${CREATION_TYPE.plugins}`}
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
@ -123,7 +123,7 @@ export const CreationTypeTabs = () => {
</span>
</Link>
<Link
href={`/?creationType=${CREATION_TYPE.templates}`}
href={`/${CREATION_TYPE.templates}`}
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',

View File

@ -92,7 +92,7 @@ const OperationDropdown: FC<Props> = ({
<PortalToFollowElemContent className="z-[9999]">
<div className="min-w-[176px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
<div onClick={handleDownload} className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">{t('operation.download', { ns: 'common' })}</div>
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="system-md-regular block cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">{t('operation.viewDetails', { ns: 'common' })}</a>
<a href={getMarketplaceUrl(`/plugin/${author}/${name}`, { theme })} target="_blank" className="system-md-regular block cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">{t('operation.viewDetails', { ns: 'common' })}</a>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>

View File

@ -93,7 +93,7 @@ export const SwitchPluginVersion: FC<SwitchPluginVersionProps> = (props) => {
modalBottomLeft={(
<Link
className="flex items-center justify-center gap-1"
href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)}
href={getMarketplaceUrl(`/plugin/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)}
target="_blank"
>
<span className="system-xs-regular text-xs text-text-accent">