mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e6b985d9 | ||
|
|
7fcbcd345a | ||
|
|
ddddb431c2 | ||
|
|
78c27579a3 | ||
|
|
9aebc35d86 |
@@ -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).
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user