Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 4 additions & 18 deletions convex/skills.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import { getPage, type IndexKey } from 'convex-helpers/server/pagination'
import schema from './schema'
import { paginationOptsValidator } from 'convex/server'
import { ConvexError, v, type Value } from 'convex/values'
import type { Doc, Id } from './_generated/dataModel'
Expand Down Expand Up @@ -330,22 +331,6 @@ const NONSUSPICIOUS_SORT_INDEXES = {
installs: 'by_nonsuspicious_installs',
} as const

// Index fields for getPage (avoids importing schema.ts which pulls in auth deps)
const DIGEST_INDEX_FIELDS: Record<string, string[]> = {
by_active_created: ['softDeletedAt', 'createdAt'],
by_active_updated: ['softDeletedAt', 'updatedAt'],
by_active_name: ['softDeletedAt', 'displayName'],
by_active_stats_downloads: ['softDeletedAt', 'statsDownloads', 'updatedAt'],
by_active_stats_stars: ['softDeletedAt', 'statsStars', 'updatedAt'],
by_active_stats_installs_all_time: ['softDeletedAt', 'statsInstallsAllTime', 'updatedAt'],
by_nonsuspicious_created: ['softDeletedAt', 'isSuspicious', 'createdAt'],
by_nonsuspicious_updated: ['softDeletedAt', 'isSuspicious', 'updatedAt'],
by_nonsuspicious_name: ['softDeletedAt', 'isSuspicious', 'displayName'],
by_nonsuspicious_downloads: ['softDeletedAt', 'isSuspicious', 'statsDownloads', 'updatedAt'],
by_nonsuspicious_stars: ['softDeletedAt', 'isSuspicious', 'statsStars', 'updatedAt'],
by_nonsuspicious_installs: ['softDeletedAt', 'isSuspicious', 'statsInstallsAllTime', 'updatedAt'],
}

function isSkillVersionId(
value: Id<'skillVersions'> | null | undefined,
): value is Id<'skillVersions'> {
Expand Down Expand Up @@ -2760,10 +2745,11 @@ export const listPublicPageV4 = query({
startInclusive: lastFetchInclusive,
endIndexKey: eqPrefix,
endInclusive: true,
targetMaxRows: fetchSize,
// endIndexKey causes targetMaxRows to be ignored, so use absoluteMaxRows
absoluteMaxRows: fetchSize,
order: dir,
index: indexName,
indexFields: DIGEST_INDEX_FIELDS[indexName],
schema,
})

// Pair digests with their index keys, then filter
Expand Down
42 changes: 42 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@

import { Route as rootRouteImport } from './routes/__root'
import { Route as UploadRouteImport } from './routes/upload'
import { Route as TestV4RouteImport } from './routes/test-v4'
import { Route as StarsRouteImport } from './routes/stars'
import { Route as Skillsv4RouteImport } from './routes/skillsv4'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SearchRouteImport } from './routes/search'
import { Route as ManagementRouteImport } from './routes/management'
Expand All @@ -30,11 +32,21 @@ const UploadRoute = UploadRouteImport.update({
path: '/upload',
getParentRoute: () => rootRouteImport,
} as any)
const TestV4Route = TestV4RouteImport.update({
id: '/test-v4',
path: '/test-v4',
getParentRoute: () => rootRouteImport,
} as any)
const StarsRoute = StarsRouteImport.update({
id: '/stars',
path: '/stars',
getParentRoute: () => rootRouteImport,
} as any)
const Skillsv4Route = Skillsv4RouteImport.update({
id: '/skillsv4',
path: '/skillsv4',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
Expand Down Expand Up @@ -109,7 +121,9 @@ export interface FileRoutesByFullPath {
'/management': typeof ManagementRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/skillsv4': typeof Skillsv4Route
'/stars': typeof StarsRoute
'/test-v4': typeof TestV4Route
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/cli/auth': typeof CliAuthRoute
Expand All @@ -126,7 +140,9 @@ export interface FileRoutesByTo {
'/management': typeof ManagementRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/skillsv4': typeof Skillsv4Route
'/stars': typeof StarsRoute
'/test-v4': typeof TestV4Route
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/cli/auth': typeof CliAuthRoute
Expand All @@ -144,7 +160,9 @@ export interface FileRoutesById {
'/management': typeof ManagementRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/skillsv4': typeof Skillsv4Route
'/stars': typeof StarsRoute
'/test-v4': typeof TestV4Route
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/cli/auth': typeof CliAuthRoute
Expand All @@ -163,7 +181,9 @@ export interface FileRouteTypes {
| '/management'
| '/search'
| '/settings'
| '/skillsv4'
| '/stars'
| '/test-v4'
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
Expand All @@ -180,7 +200,9 @@ export interface FileRouteTypes {
| '/management'
| '/search'
| '/settings'
| '/skillsv4'
| '/stars'
| '/test-v4'
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
Expand All @@ -197,7 +219,9 @@ export interface FileRouteTypes {
| '/management'
| '/search'
| '/settings'
| '/skillsv4'
| '/stars'
| '/test-v4'
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
Expand All @@ -215,7 +239,9 @@ export interface RootRouteChildren {
ManagementRoute: typeof ManagementRoute
SearchRoute: typeof SearchRoute
SettingsRoute: typeof SettingsRoute
Skillsv4Route: typeof Skillsv4Route
StarsRoute: typeof StarsRoute
TestV4Route: typeof TestV4Route
UploadRoute: typeof UploadRoute
OwnerSlugRoute: typeof OwnerSlugRoute
CliAuthRoute: typeof CliAuthRoute
Expand All @@ -234,13 +260,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof UploadRouteImport
parentRoute: typeof rootRouteImport
}
'/test-v4': {
id: '/test-v4'
path: '/test-v4'
fullPath: '/test-v4'
preLoaderRoute: typeof TestV4RouteImport
parentRoute: typeof rootRouteImport
}
'/stars': {
id: '/stars'
path: '/stars'
fullPath: '/stars'
preLoaderRoute: typeof StarsRouteImport
parentRoute: typeof rootRouteImport
}
'/skillsv4': {
id: '/skillsv4'
path: '/skillsv4'
fullPath: '/skillsv4'
preLoaderRoute: typeof Skillsv4RouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
Expand Down Expand Up @@ -343,7 +383,9 @@ const rootRouteChildren: RootRouteChildren = {
ManagementRoute: ManagementRoute,
SearchRoute: SearchRoute,
SettingsRoute: SettingsRoute,
Skillsv4Route: Skillsv4Route,
StarsRoute: StarsRoute,
TestV4Route: TestV4Route,
UploadRoute: UploadRoute,
OwnerSlugRoute: OwnerSlugRoute,
CliAuthRoute: CliAuthRoute,
Expand Down
42 changes: 30 additions & 12 deletions src/routes/skills/-useSkillsBrowseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export function useSkillsBrowseModel({
search,
navigate,
searchInputRef,
useV4 = false,
}: {
search: SkillsSearchState
navigate: SkillsNavigate
searchInputRef: RefObject<HTMLInputElement | null>
useV4?: boolean
}) {
const [query, setQuery] = useState(search.q ?? '')
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
Expand Down Expand Up @@ -70,25 +72,41 @@ export function useSkillsBrowseModel({
const fetchPage = useCallback(
async (cursor: string | null, generation: number) => {
try {
const result = await convexHttp.query(api.skills.listPublicPageV3, {
paginationOpts: { cursor, numItems: pageSize },
sort: listSort,
dir,
highlightedOnly,
nonSuspiciousOnly,
})
if (generation !== fetchGeneration.current) return
setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page))
setListCursor(result.isDone ? null : result.continueCursor)
setListStatus(result.isDone ? 'done' : 'idle')
if (useV4) {
const result = await convexHttp.query(api.skills.listPublicPageV4, {
cursor: cursor ?? undefined,
numItems: pageSize,
sort: listSort,
dir,
highlightedOnly,
nonSuspiciousOnly,
})
if (generation !== fetchGeneration.current) return
setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page))
const canAdvance = result.hasMore && result.nextCursor != null
setListCursor(canAdvance ? result.nextCursor : null)
setListStatus(canAdvance ? 'idle' : 'done')
} else {
const result = await convexHttp.query(api.skills.listPublicPageV3, {
paginationOpts: { cursor, numItems: pageSize },
sort: listSort,
dir,
highlightedOnly,
nonSuspiciousOnly,
})
if (generation !== fetchGeneration.current) return
setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page))
setListCursor(result.isDone ? null : result.continueCursor)
setListStatus(result.isDone ? 'done' : 'idle')
}
} catch (err) {
if (generation !== fetchGeneration.current) return
console.error('Failed to fetch skills page:', err)
// Reset to idle so the user can retry via "Load more"
setListStatus(cursor ? 'idle' : 'done')
}
},
[listSort, dir, highlightedOnly, nonSuspiciousOnly],
[listSort, dir, highlightedOnly, nonSuspiciousOnly, useV4],
)

// Reset and fetch first page when sort/dir/filters change
Expand Down
110 changes: 110 additions & 0 deletions src/routes/skillsv4.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useQuery } from 'convex/react'
import { useRef } from 'react'
import { api } from '../../convex/_generated/api'
import { parseSort } from './skills/-params'
import { SkillsResults } from './skills/-SkillsResults'
import { SkillsToolbar } from './skills/-SkillsToolbar'
import { useSkillsBrowseModel, type SkillsSearchState } from './skills/-useSkillsBrowseModel'

export const Route = createFileRoute('/skillsv4')({
validateSearch: (search): SkillsSearchState => {
return {
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
highlighted:
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
? true
: undefined,
nonSuspicious:
search.nonSuspicious === '1' ||
search.nonSuspicious === 'true' ||
search.nonSuspicious === true
? true
: undefined,
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
focus: search.focus === 'search' ? 'search' : undefined,
}
},
beforeLoad: ({ search }) => {
const hasQuery = Boolean(search.q?.trim())
if (hasQuery || search.sort) return
throw redirect({
to: '/skillsv4',
search: {
q: search.q || undefined,
sort: 'downloads',
dir: search.dir || undefined,
highlighted: search.highlighted || undefined,
nonSuspicious: search.nonSuspicious || undefined,
view: search.view || undefined,
focus: search.focus || undefined,
},
replace: true,
})
},
component: SkillsV4Index,
})

function SkillsV4Index() {
const navigate = Route.useNavigate()
const search = Route.useSearch()
const searchInputRef = useRef<HTMLInputElement>(null)
const totalSkills = useQuery(api.skills.countPublicSkills)
const totalSkillsText =
typeof totalSkills === 'number' ? totalSkills.toLocaleString('en-US') : null

const model = useSkillsBrowseModel({
navigate,
search,
searchInputRef,
useV4: true,
})

return (
<main className="section">
<header className="skills-header-top">
<h1 className="section-title" style={{ marginBottom: 8 }}>
Skills (V4 test)
{totalSkillsText && <span style={{ opacity: 0.55 }}>{` (${totalSkillsText})`}</span>}
</h1>
<p className="section-subtitle" style={{ marginBottom: 0 }}>
{model.isLoadingSkills
? 'Loading skills…'
: `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(', ')})` : ''}.`}
</p>
</header>
<div className="skills-container">
<SkillsToolbar
searchInputRef={searchInputRef}
query={model.query}
hasQuery={model.hasQuery}
sort={model.sort}
dir={model.dir}
view={model.view}
highlightedOnly={model.highlightedOnly}
nonSuspiciousOnly={model.nonSuspiciousOnly}
onQueryChange={model.onQueryChange}
onToggleHighlighted={model.onToggleHighlighted}
onToggleNonSuspicious={model.onToggleNonSuspicious}
onSortChange={model.onSortChange}
onToggleDir={model.onToggleDir}
onToggleView={model.onToggleView}
/>
<SkillsResults
isLoadingSkills={model.isLoadingSkills}
sorted={model.sorted}
view={model.view}
listDoneLoading={!model.isLoadingSkills && !model.canLoadMore && !model.isLoadingMore}
hasQuery={model.hasQuery}
canLoadMore={model.canLoadMore}
isLoadingMore={model.isLoadingMore}
canAutoLoad={model.canAutoLoad}
loadMoreRef={model.loadMoreRef}
loadMore={model.loadMore}
/>
</div>
</main>
)
}
Loading
Loading