Compare commits

...
Author SHA1 Message Date
Peter Steinberger c1e6b985d9 fix: add root undici devDependency for e2e (#255) (thanks @tanujbhaud) 2026-02-13 15:23:00 +01:00
Tanuj Bhaud 7fcbcd345a fix(vt): explicit return types and missing undici dependency
Refactor action handlers in convex/vt.ts to use explicit return types, resolving circular type inference (TS7022). Also add undici to devDependencies for E2E tests.
2026-02-13 15:22:07 +01:00
Peter SteinbergerandSash Zats ddddb431c2 fix: make /search host-aware in SSR (#257)
* fix: make /search mode-aware

Notes:\n- Medium: /search now depends on getSiteMode() during beforeLoad. On server-side routing, if VITE_SITE_MODE isn’t set and VITE_SOULHUB_SITE_URL is set (as in .env.local), getSiteMode() will resolve to souls and redirect /search to / even on the ClawdHub deployment. This is a regression risk vs the old always-/skills redirect. Confirm deployment envs guarantee correct mode. src/routes/search.tsx:9-31

* fix: make /search host-aware in SSR

* chore: fix lint and route tree for /search route

---------

Co-authored-by: Sash Zats <sash@zats.io>
2026-02-13 14:26:31 +01:00
David AronchickandPeter Steinberger 78c27579a3 fix(cli): secure config file permissions (#164)
* fix(cli): secure config file permissions and reduce duplication

Security:
- Config files now created with 0600 permissions (owner read/write only)
- Config directories created with 0700 permissions
- Protects API tokens from other users on shared systems

Maintainability:
- Extract resolveConfigPath() helper to reduce code duplication
- Same legacy fallback logic (clawhub -> clawdhub) now in one place

* fix(cli): tolerate unsupported chmod errors for config

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 13:57:03 +01:00
xcqtnrandPeter Steinberger 9aebc35d86 fix: prevent infinite loading loop on skills page (#90)
* fix: prevent infinite loading loop on skills pageAdd isLoadingMore guard to IntersectionObserver useEffect to preventcontinuous WebSocket queries when user is idle at bottom of page.The observer now won't set up while a request is in progress, breakingthe infinite loop cycle.Fixes: Related to #89

* fix: prevent repeated skills auto-load requests (#90) (thanks @xcqtnr)

* fix: resolve PR merge conflicts and keep observer regression test (#90) (thanks @xcqtnr)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 05:44:36 +01:00
11 changed files with 397 additions and 31 deletions
+1
View File
@@ -14,6 +14,7 @@
### Fixed
- Upload gate: handle GitHub API rate limits and optional authenticated lookup token (thanks @superlowburn, #246).
- HTTP: remove `allowH2` from Undici agent to prevent `fetch failed` on Node.js 22+ (#245).
- Tests: add root `undici` dev dependency for Node E2E imports (thanks @tanujbhaud, #255).
- VirusTotal: fix scan sync race conditions and retry behavior in scan/backfill paths.
- Metadata: tolerate trailing commas in JSON metadata.
- Auth: allow soft-deleted users to re-authenticate on fresh login, while keeping banned users blocked (thanks @tanujbhaud, #177).
+8 -3
View File
@@ -57,13 +57,14 @@
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.11.4",
"typescript": "^5.9.3",
"undici": "^7.19.2",
"vite": "^7.3.1",
"vitest": "^4.0.18",
},
},
"packages/clawdhub": {
"name": "clawhub",
"version": "0.5.0",
"version": "0.6.1",
"bin": {
"clawhub": "bin/clawdhub.js",
"clawdhub": "bin/clawdhub.js",
@@ -1298,7 +1299,7 @@
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
"undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -1402,8 +1403,12 @@
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"cheerio/undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"clawhub/undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -1412,7 +1417,7 @@
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"jsdom/undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
"nitro/undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+4 -4
View File
@@ -280,10 +280,10 @@ export const runReconcileSkillStarCountsInternal = internalAction({
let totalPatched = 0
for (let i = 0; i < maxBatches; i++) {
const result = (await ctx.runMutation(
internal.statsMaintenance.reconcileSkillStarCounts,
{ cursor, batchSize },
)) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
const result = (await ctx.runMutation(internal.statsMaintenance.reconcileSkillStarCounts, {
cursor,
batchSize,
})) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
totalScanned += result.scanned
totalPatched += result.patched
+1
View File
@@ -76,6 +76,7 @@
"only-allow": "^1.2.2",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.11.4",
"undici": "^7.19.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
+73
View File
@@ -0,0 +1,73 @@
/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const chmodMock = vi.fn()
const mkdirMock = vi.fn()
const readFileMock = vi.fn()
const writeFileMock = vi.fn()
vi.mock('node:fs/promises', () => ({
chmod: (...args: unknown[]) => chmodMock(...args),
mkdir: (...args: unknown[]) => mkdirMock(...args),
readFile: (...args: unknown[]) => readFileMock(...args),
writeFile: (...args: unknown[]) => writeFileMock(...args),
}))
const { writeGlobalConfig } = await import('./config')
const originalPlatform = process.platform
const testConfigPath = '/tmp/clawhub-config-test/config.json'
function makeErr(code: string): NodeJS.ErrnoException {
const error = new Error(code) as NodeJS.ErrnoException
error.code = code
return error
}
beforeEach(() => {
vi.stubEnv('CLAWHUB_CONFIG_PATH', testConfigPath)
Object.defineProperty(process, 'platform', { value: 'linux' })
chmodMock.mockResolvedValue(undefined)
mkdirMock.mockResolvedValue(undefined)
readFileMock.mockResolvedValue('')
writeFileMock.mockResolvedValue(undefined)
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.unstubAllEnvs()
vi.clearAllMocks()
})
describe('writeGlobalConfig', () => {
it('writes config with restricted modes', async () => {
await writeGlobalConfig({ registry: 'https://example.com', token: 'clh_test' })
expect(mkdirMock).toHaveBeenCalledWith('/tmp/clawhub-config-test', {
recursive: true,
mode: 0o700,
})
expect(writeFileMock).toHaveBeenCalledWith(
testConfigPath,
expect.stringContaining('"token": "clh_test"'),
{
encoding: 'utf8',
mode: 0o600,
},
)
expect(chmodMock).toHaveBeenCalledWith(testConfigPath, 0o600)
})
it('ignores non-fatal chmod errors', async () => {
chmodMock.mockRejectedValueOnce(makeErr('ENOTSUP'))
await expect(writeGlobalConfig({ registry: 'https://example.com' })).resolves.toBeUndefined()
})
it('rethrows unexpected chmod errors', async () => {
chmodMock.mockRejectedValueOnce(new Error('boom'))
await expect(writeGlobalConfig({ registry: 'https://example.com' })).rejects.toThrow('boom')
})
})
+48 -23
View File
@@ -1,44 +1,51 @@
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { type GlobalConfig, GlobalConfigSchema, parseArk } from './schema/index.js'
/**
* Resolve config path with legacy fallback.
* Checks for 'clawhub' first, falls back to legacy 'clawdhub' if it exists.
*/
function resolveConfigPath(baseDir: string): string {
const clawhubPath = join(baseDir, 'clawhub', 'config.json')
const clawdhubPath = join(baseDir, 'clawdhub', 'config.json')
if (existsSync(clawhubPath)) return clawhubPath
if (existsSync(clawdhubPath)) return clawdhubPath
return clawhubPath
}
function isNonFatalChmodError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const code = (error as NodeJS.ErrnoException).code
return code === 'EPERM' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'EINVAL'
}
export function getGlobalConfigPath() {
const override =
process.env.CLAWHUB_CONFIG_PATH?.trim() ?? process.env.CLAWDHUB_CONFIG_PATH?.trim()
if (override) return resolve(override)
const home = homedir()
if (process.platform === 'darwin') {
const clawhubPath = join(home, 'Library', 'Application Support', 'clawhub', 'config.json')
const clawdhubPath = join(home, 'Library', 'Application Support', 'clawdhub', 'config.json')
if (existsSync(clawhubPath)) return clawhubPath
if (existsSync(clawdhubPath)) return clawdhubPath
return clawhubPath
return resolveConfigPath(join(home, 'Library', 'Application Support'))
}
const xdg = process.env.XDG_CONFIG_HOME
if (xdg) {
const clawhubPath = join(xdg, 'clawhub', 'config.json')
const clawdhubPath = join(xdg, 'clawdhub', 'config.json')
if (existsSync(clawhubPath)) return clawhubPath
if (existsSync(clawdhubPath)) return clawdhubPath
return clawhubPath
return resolveConfigPath(xdg)
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA
if (appData) {
const clawhubPath = join(appData, 'clawhub', 'config.json')
const clawdhubPath = join(appData, 'clawdhub', 'config.json')
if (existsSync(clawhubPath)) return clawhubPath
if (existsSync(clawdhubPath)) return clawdhubPath
return clawhubPath
return resolveConfigPath(appData)
}
}
const clawhubPath = join(home, '.config', 'clawhub', 'config.json')
const clawdhubPath = join(home, '.config', 'clawdhub', 'config.json')
if (existsSync(clawhubPath)) return clawhubPath
if (existsSync(clawdhubPath)) return clawdhubPath
return clawhubPath
return resolveConfigPath(join(home, '.config'))
}
export async function readGlobalConfig(): Promise<GlobalConfig | null> {
@@ -53,6 +60,24 @@ export async function readGlobalConfig(): Promise<GlobalConfig | null> {
export async function writeGlobalConfig(config: GlobalConfig) {
const path = getGlobalConfigPath()
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
const dir = dirname(path)
// Create directory with restricted permissions (owner only)
await mkdir(dir, { recursive: true, mode: 0o700 })
// Write file with restricted permissions (owner read/write only)
// This protects API tokens from being read by other users
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
})
// Ensure permissions on existing files (writeFile mode only applies on create)
if (process.platform !== 'win32') {
try {
await chmod(path, 0o600)
} catch (error) {
if (!isNonFatalChmodError(error)) throw error
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
redirect: (options: unknown) => ({ redirect: options }),
}))
import { Route } from '../routes/search'
function runBeforeLoad(search: { q?: string; highlighted?: boolean }, hostname = 'clawdhub.com') {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: {
search: { q?: string; highlighted?: boolean }
location: { url: URL }
}) => void
}
}
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
location: { url: URL }
}) => void
let thrown: unknown
try {
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } })
} catch (error) {
thrown = error
}
return thrown
}
describe('search route', () => {
it('redirects skills host to the skills index', () => {
expect(runBeforeLoad({ q: 'crab', highlighted: true }, 'clawdhub.com')).toEqual({
redirect: {
to: '/skills',
search: {
q: 'crab',
sort: undefined,
dir: undefined,
highlighted: true,
view: undefined,
},
replace: true,
},
})
})
it('redirects souls host with query to home search', () => {
expect(runBeforeLoad({ q: 'crab', highlighted: true }, 'onlycrabs.ai')).toEqual({
redirect: {
to: '/',
search: {
q: 'crab',
highlighted: undefined,
search: undefined,
},
replace: true,
},
})
})
it('redirects souls host without query to home with search mode', () => {
expect(runBeforeLoad({}, 'onlycrabs.ai')).toEqual({
redirect: {
to: '/',
search: {
q: undefined,
highlighted: undefined,
search: true,
},
replace: true,
},
})
})
})
@@ -0,0 +1,115 @@
/* @vitest-environment jsdom */
import { act, render } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SkillsIndex } from '../routes/skills/index'
const navigateMock = vi.fn()
const useActionMock = vi.fn()
const usePaginatedQueryMock = vi.fn()
let searchMock: Record<string, unknown> = {}
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (_config: { component: unknown; validateSearch: unknown }) => ({
useNavigate: () => navigateMock,
useSearch: () => searchMock,
}),
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
}))
vi.mock('convex/react', () => ({
useAction: (...args: unknown[]) => useActionMock(...args),
}))
vi.mock('convex-helpers/react', () => ({
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
}))
describe('SkillsIndex load-more observer', () => {
beforeEach(() => {
usePaginatedQueryMock.mockReset()
useActionMock.mockReset()
navigateMock.mockReset()
searchMock = {}
useActionMock.mockReturnValue(() => Promise.resolve([]))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('triggers one request for repeated intersection callbacks', async () => {
const loadMorePaginated = vi.fn()
usePaginatedQueryMock.mockReturnValue({
results: [makeListResult('skill-0', 'Skill 0')],
status: 'CanLoadMore',
loadMore: loadMorePaginated,
})
type ObserverInstance = {
callback: IntersectionObserverCallback
observe: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
}
const observers: ObserverInstance[] = []
class IntersectionObserverMock {
callback: IntersectionObserverCallback
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
takeRecords = vi.fn(() => [])
root = null
rootMargin = '0px'
thresholds: number[] = []
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
observers.push(this)
}
}
vi.stubGlobal(
'IntersectionObserver',
IntersectionObserverMock as unknown as typeof IntersectionObserver,
)
render(<SkillsIndex />)
expect(observers).toHaveLength(1)
const observer = observers[0]
const entries = [{ isIntersecting: true }] as Array<IntersectionObserverEntry>
await act(async () => {
observer.callback(entries, observer as unknown as IntersectionObserver)
observer.callback(entries, observer as unknown as IntersectionObserver)
observer.callback(entries, observer as unknown as IntersectionObserver)
})
expect(loadMorePaginated).toHaveBeenCalledTimes(1)
})
})
function makeListResult(slug: string, displayName: string) {
return {
skill: {
_id: `skill_${slug}`,
slug,
displayName,
summary: `${displayName} summary`,
tags: {},
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
latestVersion: null,
ownerHandle: null,
}
}
+21
View File
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as UploadRouteImport } from './routes/upload'
import { Route as StarsRouteImport } from './routes/stars'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SearchRouteImport } from './routes/search'
import { Route as ManagementRouteImport } from './routes/management'
import { Route as ImportRouteImport } from './routes/import'
import { Route as DashboardRouteImport } from './routes/dashboard'
@@ -39,6 +40,11 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const SearchRoute = SearchRouteImport.update({
id: '/search',
path: '/search',
getParentRoute: () => rootRouteImport,
} as any)
const ManagementRoute = ManagementRouteImport.update({
id: '/management',
path: '/management',
@@ -101,6 +107,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/management': typeof ManagementRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
@@ -117,6 +124,7 @@ export interface FileRoutesByTo {
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/management': typeof ManagementRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
@@ -134,6 +142,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/management': typeof ManagementRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
@@ -152,6 +161,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/import'
| '/management'
| '/search'
| '/settings'
| '/stars'
| '/upload'
@@ -168,6 +178,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/import'
| '/management'
| '/search'
| '/settings'
| '/stars'
| '/upload'
@@ -184,6 +195,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/import'
| '/management'
| '/search'
| '/settings'
| '/stars'
| '/upload'
@@ -201,6 +213,7 @@ export interface RootRouteChildren {
DashboardRoute: typeof DashboardRoute
ImportRoute: typeof ImportRoute
ManagementRoute: typeof ManagementRoute
SearchRoute: typeof SearchRoute
SettingsRoute: typeof SettingsRoute
StarsRoute: typeof StarsRoute
UploadRoute: typeof UploadRoute
@@ -235,6 +248,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/search': {
id: '/search'
path: '/search'
fullPath: '/search'
preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport
}
'/management': {
id: '/management'
path: '/management'
@@ -321,6 +341,7 @@ const rootRouteChildren: RootRouteChildren = {
DashboardRoute: DashboardRoute,
ImportRoute: ImportRoute,
ManagementRoute: ManagementRoute,
SearchRoute: SearchRoute,
SettingsRoute: SettingsRoute,
StarsRoute: StarsRoute,
UploadRoute: UploadRoute,
+38
View File
@@ -0,0 +1,38 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { detectSiteMode } from '../lib/site'
export const Route = createFileRoute('/search')({
validateSearch: (search) => ({
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
}),
beforeLoad: ({ search, location }) => {
const hostname =
(location as { url?: URL }).url?.hostname ??
(typeof window !== 'undefined' ? window.location.hostname : undefined)
const mode = detectSiteMode(hostname)
if (mode === 'skills') {
throw redirect({
to: '/skills',
search: {
q: search.q || undefined,
sort: undefined,
dir: undefined,
highlighted: search.highlighted || undefined,
view: undefined,
},
replace: true,
})
}
throw redirect({
to: '/',
search: {
q: search.q || undefined,
highlighted: undefined,
search: search.q ? undefined : true,
},
replace: true,
})
},
})
+10 -1
View File
@@ -80,6 +80,7 @@ export function SkillsIndex() {
const [isSearching, setIsSearching] = useState(false)
const searchRequest = useRef(0)
const loadMoreRef = useRef<HTMLDivElement | null>(null)
const loadMoreInFlightRef = useRef(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = useMemo(() => query.trim(), [query])
@@ -211,7 +212,8 @@ export function SkillsIndex() {
const canAutoLoad = typeof IntersectionObserver !== 'undefined'
const loadMore = useCallback(() => {
if (isLoadingMore || !canLoadMore) return
if (loadMoreInFlightRef.current || isLoadingMore || !canLoadMore) return
loadMoreInFlightRef.current = true
if (hasQuery) {
setSearchLimit((value) => value + pageSize)
} else {
@@ -219,6 +221,12 @@ export function SkillsIndex() {
}
}, [canLoadMore, hasQuery, isLoadingMore, loadMorePaginated])
useEffect(() => {
if (!isLoadingMore) {
loadMoreInFlightRef.current = false
}
}, [isLoadingMore])
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === 'undefined') return
const target = loadMoreRef.current
@@ -226,6 +234,7 @@ export function SkillsIndex() {
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect()
loadMore()
}
},