From 3589f6bc2fd690b3702fe9bf197568eab70e42a7 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Tue, 27 Jan 2026 23:32:23 -0500 Subject: [PATCH 01/27] feat: enhance package list UX with table view, filters, and sorts Add advanced filtering, sorting, and view options to package list pages. - Add card/table view mode toggle with localStorage persistence - Add table view with sortable columns (name, downloads, last updated), with asc/desc toggle - Add structured filtering: text search, download range, keywords (AND), updated since - Add a few search operators for power users: `name:`, `desc:`, `kw:` (e.g. `name:react kw:hooks`) - Add column picker for table view with disabled "coming soon" columns - Add pagination options (infinite scroll vs paginated with page size) - When "Filters" panel is collapsed, show a summary of active filters in operator syntax! - Make keywords in table clickable to add to filters - Make maintainers clickable links to user pages --- app/components/ColumnPicker.vue | 164 +++++++ app/components/FilterChips.vue | 59 +++ app/components/FilterPanel.vue | 340 ++++++++++++++ app/components/PackageList.vue | 141 +++++- app/components/PackageListToolbar.vue | 204 ++++++++ app/components/PackageTable.vue | 336 ++++++++++++++ app/components/PackageTableRow.vue | 190 ++++++++ app/components/PaginationControls.vue | 210 +++++++++ app/components/ViewModeToggle.vue | 36 ++ app/composables/useNpmRegistry.ts | 128 ++++- app/composables/usePackageListPreferences.ts | 110 +++++ app/composables/usePreferencesProvider.ts | 99 ++++ app/composables/useStructuredFilters.ts | 463 +++++++++++++++++++ app/pages/@[org].vue | 186 +++++--- app/pages/search.vue | 118 ++++- i18n/locales/en.json | 102 ++++ i18n/locales/fr.json | 102 ++++ shared/types/preferences.ts | 334 +++++++++++++ test/unit/preferences.spec.ts | 78 ++++ test/unit/search-operators.spec.ts | 254 ++++++++++ test/unit/structured-filters.spec.ts | 359 ++++++++++++++ 21 files changed, 3882 insertions(+), 131 deletions(-) create mode 100644 app/components/ColumnPicker.vue create mode 100644 app/components/FilterChips.vue create mode 100644 app/components/FilterPanel.vue create mode 100644 app/components/PackageListToolbar.vue create mode 100644 app/components/PackageTable.vue create mode 100644 app/components/PackageTableRow.vue create mode 100644 app/components/PaginationControls.vue create mode 100644 app/components/ViewModeToggle.vue create mode 100644 app/composables/usePackageListPreferences.ts create mode 100644 app/composables/usePreferencesProvider.ts create mode 100644 app/composables/useStructuredFilters.ts create mode 100644 shared/types/preferences.ts create mode 100644 test/unit/preferences.spec.ts create mode 100644 test/unit/search-operators.spec.ts create mode 100644 test/unit/structured-filters.spec.ts diff --git a/app/components/ColumnPicker.vue b/app/components/ColumnPicker.vue new file mode 100644 index 0000000000..fcfe03f0bc --- /dev/null +++ b/app/components/ColumnPicker.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/app/components/FilterChips.vue b/app/components/FilterChips.vue new file mode 100644 index 0000000000..871ced6f0f --- /dev/null +++ b/app/components/FilterChips.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/app/components/FilterPanel.vue b/app/components/FilterPanel.vue new file mode 100644 index 0000000000..219990205d --- /dev/null +++ b/app/components/FilterPanel.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/app/components/PackageList.vue b/app/components/PackageList.vue index c697ba789d..2d468c940d 100644 --- a/app/components/PackageList.vue +++ b/app/components/PackageList.vue @@ -1,6 +1,13 @@ + + diff --git a/app/components/PackageTable.vue b/app/components/PackageTable.vue new file mode 100644 index 0000000000..bd1064eeb4 --- /dev/null +++ b/app/components/PackageTable.vue @@ -0,0 +1,336 @@ + + + diff --git a/app/components/PackageTableRow.vue b/app/components/PackageTableRow.vue new file mode 100644 index 0000000000..a75d987811 --- /dev/null +++ b/app/components/PackageTableRow.vue @@ -0,0 +1,190 @@ + + + diff --git a/app/components/PaginationControls.vue b/app/components/PaginationControls.vue new file mode 100644 index 0000000000..c3be0850ef --- /dev/null +++ b/app/components/PaginationControls.vue @@ -0,0 +1,210 @@ + + + diff --git a/app/components/ViewModeToggle.vue b/app/components/ViewModeToggle.vue new file mode 100644 index 0000000000..dd419d20cf --- /dev/null +++ b/app/components/ViewModeToggle.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index b0053056cd..e9bc1ae4e9 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). @@ -269,6 +337,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 @@ -278,7 +347,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] || '' @@ -290,6 +359,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}`, @@ -298,6 +368,7 @@ function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult { }, score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, searchScore: 0, + downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined, updated: modified, } } @@ -341,30 +412,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 0000000000..9ad57f100d --- /dev/null +++ b/app/composables/usePackageListPreferences.ts @@ -0,0 +1,110 @@ +/** + * 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 + */ +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] + 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 0000000000..3441c23fcd --- /dev/null +++ b/app/composables/usePreferencesProvider.ts @@ -0,0 +1,99 @@ +/** + * 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 + */ +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 0000000000..8eaeaeee80 --- /dev/null +++ b/app/composables/useStructuredFilters.ts @@ -0,0 +1,463 @@ +/** + * 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 + 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 +} + +/** + * Format parsed operators back to string representation + */ +export function formatSearchOperators(parsed: ParsedSearchOperators): string { + const parts: string[] = [] + + if (parsed.name?.length) { + parts.push(`name:${parsed.name.join(',')}`) + } + if (parsed.description?.length) { + parts.push(`desc:${parsed.description.join(',')}`) + } + if (parsed.keywords?.length) { + parts.push(`kw:${parsed.keywords.join(',')}`) + } + if (parsed.text) { + parts.push(parsed.text) + } + + return parts.join(' ') +} + +/** + * 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 +} + +/** + * Composable for structured filtering and sorting of package lists + */ +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 matchesKeywords(pkg: NpmSearchResult, keywords: string[]): boolean { + if (keywords.length === 0) return true + const pkgKeywords = pkg.package.keywords ?? [] + // AND logic: package must have ALL selected keywords + return keywords.every(k => pkgKeywords.includes(k)) + } + + 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 + } + + 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 + 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/pages/@[org].vue b/app/pages/@[org].vue index 296462c0ad..fb00ca49fd 100644 --- a/app/pages/@[org].vue +++ b/app/pages/@[org].vue @@ -1,5 +1,6 @@