diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ce690c04..54ab096a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -189,6 +189,18 @@ import { hasProtocol } from 'ufo' | Constants | SCREAMING_SNAKE_CASE | `NPM_REGISTRY`, `ALLOWED_TAGS` | | Types/Interfaces | PascalCase | `NpmSearchResponse` | +> [!TIP] +> Exports in `app/composables/`, `app/utils/`, and `server/utils/` are auto-imported by Nuxt. To prevent [knip](https://knip.dev/) from flagging them as unused, add a `@public` JSDoc annotation: +> +> ```typescript +> /** +> * @public +> */ +> export function myAutoImportedFunction() { +> // ... +> } +> ``` + ### Vue components - Use Composition API with ` + + + + diff --git a/app/components/FilterChips.vue b/app/components/FilterChips.vue new file mode 100644 index 000000000..ff1383038 --- /dev/null +++ b/app/components/FilterChips.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/app/components/FilterPanel.vue b/app/components/FilterPanel.vue new file mode 100644 index 000000000..8b2857125 --- /dev/null +++ b/app/components/FilterPanel.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/app/components/PackageList.vue b/app/components/PackageList.vue index 4a5c221dd..ba2614ea6 100644 --- a/app/components/PackageList.vue +++ b/app/components/PackageList.vue @@ -1,6 +1,14 @@ + + diff --git a/app/components/PackageTable.vue b/app/components/PackageTable.vue new file mode 100644 index 000000000..6b62d49af --- /dev/null +++ b/app/components/PackageTable.vue @@ -0,0 +1,338 @@ + + + diff --git a/app/components/PackageTableRow.vue b/app/components/PackageTableRow.vue new file mode 100644 index 000000000..dbe88501a --- /dev/null +++ b/app/components/PackageTableRow.vue @@ -0,0 +1,187 @@ + + + diff --git a/app/components/PaginationControls.vue b/app/components/PaginationControls.vue new file mode 100644 index 000000000..1e0654bf8 --- /dev/null +++ b/app/components/PaginationControls.vue @@ -0,0 +1,239 @@ + + + diff --git a/app/components/ViewModeToggle.vue b/app/components/ViewModeToggle.vue new file mode 100644 index 000000000..ba9319858 --- /dev/null +++ b/app/components/ViewModeToggle.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 0f3ed9e50..6b6956783 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -20,6 +20,74 @@ const NPM_API = 'https://api.npmjs.org' // Cache for packument fetches to avoid duplicate requests across components const packumentCache = new Map>() +/** + * Fetch downloads for multiple packages. + * Returns a map of package name -> weekly downloads. + * Uses bulk API for unscoped packages, parallel individual requests for scoped. + * Note: npm bulk downloads API does not support scoped packages. + */ +async function fetchBulkDownloads(packageNames: string[]): Promise> { + const downloads = new Map() + if (packageNames.length === 0) return downloads + + // Separate scoped and unscoped packages + const scopedPackages = packageNames.filter(n => n.startsWith('@')) + const unscopedPackages = packageNames.filter(n => !n.startsWith('@')) + + // Fetch unscoped packages via bulk API (max 128 per request) + const bulkPromises: Promise[] = [] + const chunkSize = 100 + for (let i = 0; i < unscopedPackages.length; i += chunkSize) { + const chunk = unscopedPackages.slice(i, i + chunkSize) + bulkPromises.push( + (async () => { + try { + const response = await $fetch>( + `${NPM_API}/downloads/point/last-week/${chunk.join(',')}`, + ) + for (const [name, data] of Object.entries(response)) { + if (data?.downloads !== undefined) { + downloads.set(name, data.downloads) + } + } + } catch { + // Ignore errors - downloads are optional + } + })(), + ) + } + + // Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API) + // Use Promise.allSettled to not fail on individual errors + const scopedBatchSize = 20 // Concurrent requests per batch + for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) { + const batch = scopedPackages.slice(i, i + scopedBatchSize) + bulkPromises.push( + (async () => { + const results = await Promise.allSettled( + batch.map(async name => { + const encoded = encodePackageName(name) + const data = await $fetch<{ downloads: number }>( + `${NPM_API}/downloads/point/last-week/${encoded}`, + ) + return { name, downloads: data.downloads } + }), + ) + for (const result of results) { + if (result.status === 'fulfilled' && result.value.downloads !== undefined) { + downloads.set(result.value.name, result.value.downloads) + } + } + })(), + ) + } + + // Wait for all fetches to complete + await Promise.all(bulkPromises) + + return downloads +} + /** * Encode a package name for use in npm registry URLs. * Handles scoped packages (e.g., @scope/name -> @scope%2Fname). @@ -195,40 +263,173 @@ const emptySearchResponse = { time: new Date().toISOString(), } satisfies NpmSearchResponse +export interface NpmSearchOptions { + /** Number of results to fetch */ + size?: number +} + /** @public */ export function useNpmSearch( query: MaybeRefOrGetter, - options: MaybeRefOrGetter<{ - size?: number - from?: number - }> = {}, + options: MaybeRefOrGetter = {}, ) { const cachedFetch = useCachedFetch() + // Client-side cache + const cache = shallowRef<{ + query: string + objects: NpmSearchResult[] + total: number + } | null>(null) + + const isLoadingMore = ref(false) + + // Standard (non-incremental) search implementation let lastSearch: NpmSearchResponse | undefined = undefined - return useLazyAsyncData( - () => `search:${toValue(query)}:${JSON.stringify(toValue(options))}`, + const asyncData = useLazyAsyncData( + `search:incremental:${toValue(query)}`, async () => { const q = toValue(query) if (!q.trim()) { - return Promise.resolve(emptySearchResponse) + return emptySearchResponse } + const opts = toValue(options) + + // This only runs for initial load or query changes + // Reset cache for new query + cache.value = null + const params = new URLSearchParams() params.set('text', q) - const opts = toValue(options) - if (opts.size) params.set('size', String(opts.size)) - if (opts.from) params.set('from', String(opts.from)) + // Use requested size for initial fetch + params.set('size', String(opts.size ?? 25)) - // Note: Search results have a short TTL (1 minute) since they change frequently - return (lastSearch = await cachedFetch( + const response = await cachedFetch( `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, {}, - 60, // 1 minute TTL for search results - )) + 60, + ) + + cache.value = { + query: q, + objects: response.objects, + total: response.total, + } + + return response }, { default: () => lastSearch || emptySearchResponse }, ) + + // Fetch more results incrementally (only used in incremental mode) + async function fetchMore(targetSize: number): Promise { + const q = toValue(query).trim() + if (!q) { + cache.value = null + return + } + + // If query changed, reset cache (shouldn't happen, but safety check) + if (cache.value && cache.value.query !== q) { + cache.value = null + await asyncData.refresh() + return + } + + const currentCount = cache.value?.objects.length ?? 0 + const total = cache.value?.total ?? Infinity + + // Already have enough or no more to fetch + if (currentCount >= targetSize || currentCount >= total) { + return + } + + isLoadingMore.value = true + + try { + // Fetch from where we left off - calculate size needed + const from = currentCount + const size = Math.min(targetSize - currentCount, total - currentCount) + + const params = new URLSearchParams() + params.set('text', q) + params.set('size', String(size)) + params.set('from', String(from)) + + const response = await cachedFetch( + `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, + {}, + 60, + ) + + // Update cache + if (cache.value && cache.value.query === q) { + cache.value = { + query: q, + objects: [...cache.value.objects, ...response.objects], + total: response.total, + } + } else { + cache.value = { + query: q, + objects: response.objects, + total: response.total, + } + } + + // If we still need more, fetch again recursively + if ( + cache.value.objects.length < targetSize && + cache.value.objects.length < cache.value.total + ) { + await fetchMore(targetSize) + } + } finally { + isLoadingMore.value = false + } + } + + // Watch for size increases in incremental mode + watch( + () => toValue(options).size, + async (newSize, oldSize) => { + if (!newSize) return + if (oldSize && newSize > oldSize && toValue(query).trim()) { + await fetchMore(newSize) + } + }, + ) + + // Computed data that uses cache in incremental mode + const data = computed(() => { + if (cache.value) { + return { + objects: cache.value.objects, + total: cache.value.total, + time: new Date().toISOString(), + } + } + return asyncData.data.value + }) + + // Whether there are more results available on the server (incremental mode only) + const hasMore = computed(() => { + if (!cache.value) return true + return cache.value.objects.length < cache.value.total + }) + + return { + ...asyncData, + /** Reactive search results (uses cache in incremental mode) */ + data, + /** Whether currently loading more results (incremental mode only) */ + isLoadingMore, + /** Whether there are more results available (incremental mode only) */ + hasMore, + /** Manually fetch more results up to target size (incremental mode only) */ + fetchMore, + } } /** @@ -237,6 +438,7 @@ export function useNpmSearch( interface MinimalPackument { 'name': string 'description'?: string + 'keywords'?: string[] // `dist-tags` can be missing in some later unpublished packages 'dist-tags'?: Record 'time': Record @@ -246,7 +448,7 @@ interface MinimalPackument { /** * Convert packument to search result format for display */ -function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult { +function packumentToSearchResult(pkg: MinimalPackument, weeklyDownloads?: number): NpmSearchResult { let latestVersion = '' if (pkg['dist-tags']) { latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || '' @@ -258,6 +460,7 @@ function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult { name: pkg.name, version: latestVersion, description: pkg.description, + keywords: pkg.keywords, date: pkg.time[latestVersion] || modified, links: { npm: `https://www.npmjs.com/package/${pkg.name}`, @@ -266,7 +469,8 @@ function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult { }, score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, searchScore: 0, - updated: modified, + downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined, + updated: pkg.time[latestVersion] || modified, } } @@ -310,30 +514,41 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { return emptySearchResponse } - // Fetch packuments in parallel (with concurrency limit) - const concurrency = 10 - const results: NpmSearchResult[] = [] - - for (let i = 0; i < packageNames.length; i += concurrency) { - const batch = packageNames.slice(i, i + concurrency) - const packuments = await Promise.all( - batch.map(async name => { - try { - const encoded = encodePackageName(name) - return await cachedFetch(`${NPM_REGISTRY}/${encoded}`) - } catch { - return null + // Fetch packuments and downloads in parallel + const [packuments, downloads] = await Promise.all([ + // Fetch packuments in parallel (with concurrency limit) + (async () => { + const concurrency = 10 + const results: MinimalPackument[] = [] + for (let i = 0; i < packageNames.length; i += concurrency) { + const batch = packageNames.slice(i, i + concurrency) + const batchResults = await Promise.all( + batch.map(async name => { + try { + const encoded = encodePackageName(name) + return await cachedFetch(`${NPM_REGISTRY}/${encoded}`) + } catch { + return null + } + }), + ) + for (const pkg of batchResults) { + // Filter out any unpublished packages (missing dist-tags) + if (pkg && pkg['dist-tags']) { + results.push(pkg) + } } - }), - ) - - for (const pkg of packuments) { - // Filter out any unpublished packages (missing dist-tags) - if (pkg && pkg['dist-tags']) { - results.push(packumentToSearchResult(pkg)) } - } - } + return results + })(), + // Fetch downloads in bulk + fetchBulkDownloads(packageNames), + ]) + + // Convert to search results with download data + const results: NpmSearchResult[] = packuments.map(pkg => + packumentToSearchResult(pkg, downloads.get(pkg.name)), + ) return { objects: results, diff --git a/app/composables/usePackageListPreferences.ts b/app/composables/usePackageListPreferences.ts new file mode 100644 index 000000000..af8f9c811 --- /dev/null +++ b/app/composables/usePackageListPreferences.ts @@ -0,0 +1,112 @@ +/** + * Manages view mode, columns, and pagination preferences for package lists + */ +import type { + ColumnConfig, + ColumnId, + PackageListPreferences, + PageSize, + PaginationMode, + ViewMode, +} from '#shared/types/preferences' +import { DEFAULT_COLUMNS, DEFAULT_PREFERENCES } from '#shared/types/preferences' + +/** + * Composable for managing package list display preferences + * Persists to localStorage and provides reactive state + * + * @public + */ +export function usePackageListPreferences() { + const { + data: preferences, + isHydrated, + save, + reset, + } = usePreferencesProvider(DEFAULT_PREFERENCES) + + // Computed accessors for common properties + const viewMode = computed({ + get: () => preferences.value.viewMode, + set: (value: ViewMode) => { + preferences.value.viewMode = value + save() + }, + }) + + const paginationMode = computed({ + get: () => preferences.value.paginationMode, + set: (value: PaginationMode) => { + preferences.value.paginationMode = value + save() + }, + }) + + const pageSize = computed({ + get: () => preferences.value.pageSize, + set: (value: PageSize) => { + preferences.value.pageSize = value + save() + }, + }) + + const columns = computed({ + get: () => preferences.value.columns, + set: (value: ColumnConfig[]) => { + preferences.value.columns = value + save() + }, + }) + + // Get visible columns only + const visibleColumns = computed(() => columns.value.filter(col => col.visible)) + + // Column visibility helpers + function setColumnVisibility(columnId: ColumnId, visible: boolean) { + const column = columns.value.find(col => col.id === columnId) + if (column) { + column.visible = visible + save() + } + } + + function toggleColumn(columnId: ColumnId) { + const column = columns.value.find(col => col.id === columnId) + if (column) { + column.visible = !column.visible + save() + } + } + + function resetColumns() { + preferences.value.columns = DEFAULT_COLUMNS.map(col => Object.assign({}, col)) + save() + } + + // Check if column is visible + function isColumnVisible(columnId: ColumnId) { + return columns.value.find(col => col.id === columnId)?.visible ?? false + } + + return { + // Raw preferences + preferences, + isHydrated, + + // Individual properties with setters + viewMode, + paginationMode, + pageSize, + columns, + visibleColumns, + + // Column helpers + setColumnVisibility, + toggleColumn, + resetColumns, + isColumnVisible, + + // Reset all + reset, + } +} diff --git a/app/composables/usePreferencesProvider.ts b/app/composables/usePreferencesProvider.ts new file mode 100644 index 000000000..4d6647261 --- /dev/null +++ b/app/composables/usePreferencesProvider.ts @@ -0,0 +1,101 @@ +/** + * Abstraction for preferences storage + * Currently uses localStorage, designed for future user prefs API + */ + +const STORAGE_KEY = 'npmx-list-prefs' + +interface StorageProvider { + get: () => T | null + set: (value: T) => void + remove: () => void +} + +/** + * Creates a localStorage-based storage provider + */ +function createLocalStorageProvider(key: string): StorageProvider { + return { + get: () => { + if (import.meta.server) return null + try { + const stored = localStorage.getItem(key) + if (stored) { + return JSON.parse(stored) as T + } + } catch { + // Corrupted data, remove it + localStorage.removeItem(key) + } + return null + }, + set: (value: T) => { + if (import.meta.server) return + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Storage full or other error, fail silently + } + }, + remove: () => { + if (import.meta.server) return + localStorage.removeItem(key) + }, + } +} + +// Future: API-based provider would look like this: +// function createApiStorageProvider(endpoint: string): StorageProvider { +// return { +// get: async () => { /* fetch from API */ }, +// set: async (value) => { /* POST to API */ }, +// remove: async () => { /* DELETE from API */ }, +// } +// } + +/** + * Composable for managing preferences storage + * Abstracts the storage mechanism to allow future migration to API-based storage + * + * @public + */ +export function usePreferencesProvider(defaultValue: T) { + const provider = createLocalStorageProvider(STORAGE_KEY) + const data = ref(defaultValue) as Ref + const isHydrated = ref(false) + + // Load from storage on client + onMounted(() => { + const stored = provider.get() + if (stored) { + // Merge stored values with defaults to handle schema evolution + data.value = { ...defaultValue, ...stored } + } + isHydrated.value = true + }) + + // Persist changes + function save() { + provider.set(data.value) + } + + // Reset to defaults + function reset() { + data.value = { ...defaultValue } + provider.remove() + } + + // Update specific keys + function update(key: K, value: T[K]) { + data.value[key] = value + save() + } + + return { + data, + isHydrated, + save, + reset, + update, + } +} diff --git a/app/composables/useStructuredFilters.ts b/app/composables/useStructuredFilters.ts new file mode 100644 index 000000000..8d337fdd2 --- /dev/null +++ b/app/composables/useStructuredFilters.ts @@ -0,0 +1,450 @@ +/** + * Filter pipeline and sorting logic for package lists + */ +import type { NpmSearchResult } from '#shared/types/npm-registry' +import type { + DownloadRange, + FilterChip, + SearchScope, + SecurityFilter, + SortOption, + StructuredFilters, + UpdatedWithin, +} from '#shared/types/preferences' +import { + DEFAULT_FILTERS, + DOWNLOAD_RANGES, + parseSortOption, + SECURITY_FILTER_OPTIONS, + UPDATED_WITHIN_OPTIONS, +} from '#shared/types/preferences' + +/** + * Parsed search operators from text input + */ +export interface ParsedSearchOperators { + name?: string[] + description?: string[] + keywords?: string[] + text?: string // Remaining text without operators +} + +/** + * Parse search operators from text input. + * Supports: name:, desc:/description:, kw:/keyword: + * Multiple values can be comma-separated: kw:foo,bar + * Remaining text is treated as a general search term. + * + * Example: "name:react kw:typescript,hooks some text" + * Returns: { name: ['react'], keywords: ['typescript', 'hooks'], text: 'some text' } + */ +export function parseSearchOperators(input: string): ParsedSearchOperators { + const result: ParsedSearchOperators = {} + + // Regex to match operators: name:value, desc:value, description:value, kw:value, keyword:value + // Value continues until whitespace or next operator + const operatorRegex = /\b(name|desc|description|kw|keyword):([^\s]+)/gi + + let remaining = input + let match + + while ((match = operatorRegex.exec(input)) !== null) { + const [fullMatch, operator, value] = match + if (!operator || !value) continue + + const values = value + .split(',') + .map(v => v.trim()) + .filter(Boolean) + + const normalizedOp = operator.toLowerCase() + if (normalizedOp === 'name') { + result.name = [...(result.name ?? []), ...values] + } else if (normalizedOp === 'desc' || normalizedOp === 'description') { + result.description = [...(result.description ?? []), ...values] + } else if (normalizedOp === 'kw' || normalizedOp === 'keyword') { + result.keywords = [...(result.keywords ?? []), ...values] + } + + // Remove matched operator from remaining text + remaining = remaining.replace(fullMatch, '') + } + + // Clean up remaining text + const cleanedText = remaining.trim().replace(/\s+/g, ' ') + if (cleanedText) { + result.text = cleanedText + } + + return result +} + +/** + * Check if parsed operators has any content + */ +export function hasSearchOperators(parsed: ParsedSearchOperators): boolean { + return !!(parsed.name?.length || parsed.description?.length || parsed.keywords?.length) +} + +interface UseStructuredFiltersOptions { + packages: Ref + initialFilters?: Partial + initialSort?: SortOption +} + +// Pure filter predicates (no closure dependencies) +function matchesKeywords(pkg: NpmSearchResult, keywords: string[]): boolean { + if (keywords.length === 0) return true + const pkgKeywords = new Set((pkg.package.keywords ?? []).map(k => k.toLowerCase())) + // AND logic: package must have ALL selected keywords (case-insensitive) + return keywords.every(k => pkgKeywords.has(k.toLowerCase())) +} + +function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolean { + if (security === 'all') return true + const hasWarnings = (pkg.flags?.insecure ?? 0) > 0 + if (security === 'secure') return !hasWarnings + if (security === 'warnings') return hasWarnings + return true +} + +/** + * Composable for structured filtering and sorting of package lists + * + * @public + */ +export function useStructuredFilters(options: UseStructuredFiltersOptions) { + const { packages, initialFilters, initialSort } = options + + // Filter state + const filters = ref({ + ...DEFAULT_FILTERS, + ...initialFilters, + }) + + // Sort state + const sortOption = ref(initialSort ?? 'updated-desc') + + // Available keywords extracted from all packages + const availableKeywords = computed(() => { + const keywordCounts = new Map() + for (const pkg of packages.value) { + const keywords = pkg.package.keywords ?? [] + for (const keyword of keywords) { + keywordCounts.set(keyword, (keywordCounts.get(keyword) ?? 0) + 1) + } + } + // Sort by count descending + return Array.from(keywordCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([keyword]) => keyword) + }) + + // Filter predicates + function matchesTextFilter(pkg: NpmSearchResult, text: string, scope: SearchScope): boolean { + if (!text) return true + + const pkgName = pkg.package.name.toLowerCase() + const pkgDescription = (pkg.package.description ?? '').toLowerCase() + const pkgKeywords = (pkg.package.keywords ?? []).map(k => k.toLowerCase()) + + // When scope is 'all', parse and handle operators + if (scope === 'all') { + const parsed = parseSearchOperators(text) + + // If operators are present, use structured matching + if (hasSearchOperators(parsed)) { + // All specified operators must match (AND logic between operator types) + // Within each operator, any value can match (OR logic within operator) + + if (parsed.name?.length) { + const nameMatches = parsed.name.some(n => pkgName.includes(n.toLowerCase())) + if (!nameMatches) return false + } + + if (parsed.description?.length) { + const descMatches = parsed.description.some(d => pkgDescription.includes(d.toLowerCase())) + if (!descMatches) return false + } + + if (parsed.keywords?.length) { + const kwMatches = parsed.keywords.some(kw => + pkgKeywords.some(pk => pk.includes(kw.toLowerCase())), + ) + if (!kwMatches) return false + } + + // If there's remaining text, it must match somewhere + if (parsed.text) { + const textLower = parsed.text.toLowerCase() + const textMatches = + pkgName.includes(textLower) || + pkgDescription.includes(textLower) || + pkgKeywords.some(k => k.includes(textLower)) + if (!textMatches) return false + } + + return true + } + + // No operators - fall through to standard 'all' search + const lower = text.toLowerCase() + return ( + pkgName.includes(lower) || + pkgDescription.includes(lower) || + pkgKeywords.some(k => k.includes(lower)) + ) + } + + // Non-'all' scopes - simple matching + const lower = text.toLowerCase() + switch (scope) { + case 'name': + return pkgName.includes(lower) + case 'description': + return pkgDescription.includes(lower) + case 'keywords': + return pkgKeywords.some(k => k.includes(lower)) + default: + return pkgName.includes(lower) + } + } + + function matchesDownloadRange(pkg: NpmSearchResult, range: DownloadRange): boolean { + if (range === 'any') return true + const downloads = pkg.downloads?.weekly ?? 0 + const config = DOWNLOAD_RANGES.find(r => r.value === range) + if (!config) return true + if (config.min !== undefined && downloads < config.min) return false + if (config.max !== undefined && downloads >= config.max) return false + return true + } + + function matchesUpdatedWithin(pkg: NpmSearchResult, within: UpdatedWithin): boolean { + if (within === 'any') return true + const config = UPDATED_WITHIN_OPTIONS.find(o => o.value === within) + if (!config?.days) return true + + const updatedDate = new Date(pkg.updated ?? pkg.package.date) + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - config.days) + return updatedDate >= cutoff + } + + // Apply all filters + const filteredPackages = computed(() => { + return packages.value.filter(pkg => { + if (!matchesTextFilter(pkg, filters.value.text, filters.value.searchScope)) return false + if (!matchesDownloadRange(pkg, filters.value.downloadRange)) return false + if (!matchesKeywords(pkg, filters.value.keywords)) return false + if (!matchesSecurity(pkg, filters.value.security)) return false + if (!matchesUpdatedWithin(pkg, filters.value.updatedWithin)) return false + return true + }) + }) + + // Sort comparators + function comparePackages(a: NpmSearchResult, b: NpmSearchResult, option: SortOption): number { + const { key, direction } = parseSortOption(option) + const multiplier = direction === 'asc' ? 1 : -1 + + let diff: number + switch (key) { + case 'downloads-week': + diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0) + break + case 'downloads-day': + case 'downloads-month': + case 'downloads-year': + // Not yet implemented - fall back to weekly + diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0) + break + case 'updated': + diff = + new Date(a.updated ?? a.package.date).getTime() - + new Date(b.updated ?? b.package.date).getTime() + break + case 'name': + diff = a.package.name.localeCompare(b.package.name) + break + case 'quality': + diff = (a.score?.detail?.quality ?? 0) - (b.score?.detail?.quality ?? 0) + break + case 'popularity': + diff = (a.score?.detail?.popularity ?? 0) - (b.score?.detail?.popularity ?? 0) + break + case 'maintenance': + diff = (a.score?.detail?.maintenance ?? 0) - (b.score?.detail?.maintenance ?? 0) + break + case 'score': + diff = (a.score?.final ?? 0) - (b.score?.final ?? 0) + break + case 'relevance': + // Relevance preserves server order (already sorted by search relevance) + diff = 0 + break + default: + diff = 0 + } + + return diff * multiplier + } + + // Apply sorting to filtered results + const sortedPackages = computed(() => { + return [...filteredPackages.value].sort((a, b) => comparePackages(a, b, sortOption.value)) + }) + + // Active filter chips for display + const activeFilters = computed(() => { + const chips: FilterChip[] = [] + + if (filters.value.text) { + chips.push({ + id: 'text', + type: 'text', + label: 'Search', + value: filters.value.text, + }) + } + + if (filters.value.downloadRange !== 'any') { + const config = DOWNLOAD_RANGES.find(r => r.value === filters.value.downloadRange) + chips.push({ + id: 'downloadRange', + type: 'downloadRange', + label: 'Downloads', + value: config?.label ?? filters.value.downloadRange, + }) + } + + for (const keyword of filters.value.keywords) { + chips.push({ + id: `keyword-${keyword}`, + type: 'keywords', + label: 'Keyword', + value: keyword, + }) + } + + if (filters.value.security !== 'all') { + const config = SECURITY_FILTER_OPTIONS.find(o => o.value === filters.value.security) + chips.push({ + id: 'security', + type: 'security', + label: 'Security', + value: config?.label ?? filters.value.security, + }) + } + + if (filters.value.updatedWithin !== 'any') { + const config = UPDATED_WITHIN_OPTIONS.find(o => o.value === filters.value.updatedWithin) + chips.push({ + id: 'updatedWithin', + type: 'updatedWithin', + label: 'Updated', + value: config?.label ?? filters.value.updatedWithin, + }) + } + + return chips + }) + + // Check if any filters are active + const hasActiveFilters = computed(() => activeFilters.value.length > 0) + + // Filter update helpers + function setTextFilter(text: string) { + filters.value.text = text + } + + function setSearchScope(scope: SearchScope) { + filters.value.searchScope = scope + } + + function setDownloadRange(range: DownloadRange) { + filters.value.downloadRange = range + } + + function addKeyword(keyword: string) { + if (!filters.value.keywords.includes(keyword)) { + filters.value.keywords = [...filters.value.keywords, keyword] + } + } + + function removeKeyword(keyword: string) { + filters.value.keywords = filters.value.keywords.filter(k => k !== keyword) + } + + function toggleKeyword(keyword: string) { + if (filters.value.keywords.includes(keyword)) { + removeKeyword(keyword) + } else { + addKeyword(keyword) + } + } + + function setSecurity(security: SecurityFilter) { + filters.value.security = security + } + + function setUpdatedWithin(within: UpdatedWithin) { + filters.value.updatedWithin = within + } + + function clearFilter(chip: FilterChip) { + switch (chip.type) { + case 'text': + filters.value.text = '' + break + case 'downloadRange': + filters.value.downloadRange = 'any' + break + case 'keywords': + removeKeyword(chip.value as string) + break + case 'security': + filters.value.security = 'all' + break + case 'updatedWithin': + filters.value.updatedWithin = 'any' + break + } + } + + function clearAllFilters() { + filters.value = { ...DEFAULT_FILTERS } + } + + function setSort(option: SortOption) { + sortOption.value = option + } + + return { + // State + filters, + sortOption, + + // Derived + filteredPackages, + sortedPackages, + availableKeywords, + activeFilters, + hasActiveFilters, + + // Filter setters + setTextFilter, + setSearchScope, + setDownloadRange, + addKeyword, + removeKeyword, + toggleKeyword, + setSecurity, + setUpdatedWithin, + clearFilter, + clearAllFilters, + + // Sort setter + setSort, + } +} diff --git a/app/composables/useVirtualInfiniteScroll.ts b/app/composables/useVirtualInfiniteScroll.ts index a9d320e2d..4b0160aa1 100644 --- a/app/composables/useVirtualInfiniteScroll.ts +++ b/app/composables/useVirtualInfiniteScroll.ts @@ -1,4 +1,4 @@ -import type { Ref } from 'vue' +import type { MaybeRefOrGetter, Ref } from 'vue' export interface WindowVirtualizerHandle { readonly scrollOffset: number @@ -20,8 +20,8 @@ export interface UseVirtualInfiniteScrollOptions { hasMore: Ref /** Whether currently loading */ isLoading: Ref - /** Page size for calculating current page */ - pageSize: number + /** Page size for calculating current page (reactive) */ + pageSize: MaybeRefOrGetter /** Threshold in items before end to trigger load */ threshold?: number /** Callback to load more items */ @@ -60,7 +60,8 @@ export function useVirtualInfiniteScroll(options: UseVirtualInfiniteScrollOption // Calculate current visible page based on first visible item const startIndex = list.findItemIndex(list.scrollOffset) - const newPage = Math.floor(startIndex / pageSize) + 1 + const currentPageSize = toValue(pageSize) + const newPage = Math.floor(startIndex / currentPageSize) + 1 if (newPage !== currentPage.value && onPageChange) { currentPage.value = newPage @@ -92,7 +93,7 @@ export function useVirtualInfiniteScroll(options: UseVirtualInfiniteScrollOption const list = listRef.value if (!list || page < 1) return - const targetIndex = (page - 1) * pageSize + const targetIndex = (page - 1) * toValue(pageSize) list.scrollToIndex(targetIndex, { align: 'start' }) currentPage.value = page } diff --git a/app/pages/@[org].vue b/app/pages/@[org].vue index 296462c0a..328e1a689 100644 --- a/app/pages/@[org].vue +++ b/app/pages/@[org].vue @@ -1,5 +1,6 @@