Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3589f6b
feat: enhance package list UX with table view, filters, and sorts
serhalp Jan 28, 2026
02794dc
Merge branch 'main' into feat/org-packages-fancy
serhalp Jan 28, 2026
cfcd5c7
Merge remote-tracking branch 'origin/main' into feat/org-packages-fancy
danielroe Jan 28, 2026
a24adba
Merge remote-tracking branch 'origin/main' into feat/org-packages-fancy
danielroe Jan 28, 2026
58b1b43
fix: small type fixes + tweaks
danielroe Jan 28, 2026
e024236
refactor: use `$t`
danielroe Jan 28, 2026
05f4d3d
refactor: use `#shared/` alias
danielroe Jan 28, 2026
8db674b
chore: fix type
danielroe Jan 28, 2026
daae519
fix: add focus-visible states, use Intl date formatting, fix a11y issues
danielroe Jan 28, 2026
e7ce1d5
fix: merge duplicate class attributes in PackageTable.vue
danielroe Jan 28, 2026
686182e
test: move structured-filters tests to nuxt runtime
danielroe Jan 28, 2026
55052a4
test: add a11y tests for new filter/table components
danielroe Jan 28, 2026
57ca4ac
fix: resolve type errors in test files
danielroe Jan 28, 2026
fbaf2d9
fix: use version publish date instead of modified date for 'updated' …
danielroe Jan 28, 2026
2a10d20
fix: add defensive checks for invalid dates and case-insensitive keyw…
danielroe Jan 28, 2026
4d8bcea
Merge branch 'main' into feat/org-packages-fancy
serhalp Jan 29, 2026
973ac3f
feat: polish package search and org packages UX
serhalp Jan 29, 2026
0e8473e
fix: show the actual total search results count
serhalp Jan 29, 2026
47ffeb3
Merge branch 'main' into feat/org-packages-fancy
serhalp Jan 29, 2026
f9c28eb
fix: fix type errors from rebase
serhalp Jan 29, 2026
65b14fb
fix: fix a few more type errors
serhalp Jan 29, 2026
16bbc12
Merge branch 'main' into feat/org-packages-fancy
serhalp Jan 29, 2026
68b51f6
chore: update translations
serhalp Jan 29, 2026
f066e1e
fix: add JSDoc @public tags to composables for knip
serhalp Jan 29, 2026
5c36c14
docs: add note about knip and auto-imported exports
serhalp Jan 29, 2026
8d773b7
refactor: remove unused function
serhalp Jan 29, 2026
1916657
Merge branch 'main' into feat/org-packages-fancy
serhalp Jan 29, 2026
9b241e1
fix(i18n): better French translation for "updated within"
serhalp Jan 29, 2026
7b952e2
Merge branch 'main' into feat/org-packages-fancy
danielroe Jan 29, 2026
e167fb1
refactor: use `defineModel`
danielroe Jan 29, 2026
8556bd8
fix: load more when page increases
danielroe Jan 29, 2026
7b60c07
fix: make page size/total pages reactive
danielroe Jan 29, 2026
88ddf89
fix: hide sort order on search page
danielroe Jan 29, 2026
4c778dd
fix: use incremental searching
danielroe Jan 29, 2026
d4da810
Merge remote-tracking branch 'origin/main' into feat/org-packages-fancy
danielroe Jan 29, 2026
fbac160
chore: @
danielroe Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script setup lang="ts">`
Expand Down
166 changes: 166 additions & 0 deletions app/components/ColumnPicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<script setup lang="ts">
import type { ColumnConfig, ColumnId } from '#shared/types/preferences'

const props = defineProps<{
columns: ColumnConfig[]
}>()

const emit = defineEmits<{
toggle: [columnId: ColumnId]
reset: []
}>()

const isOpen = ref(false)
const buttonRef = useTemplateRef('buttonRef')
const menuRef = useTemplateRef('menuRef')
const menuId = useId()

// Close on click outside (check both button and menu)
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node
const isOutsideButton = buttonRef.value && !buttonRef.value.contains(target)
const isOutsideMenu = !menuRef.value || !menuRef.value.contains(target)
if (isOutsideButton && isOutsideMenu) {
isOpen.value = false
}
}

// Close on Escape key
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
buttonRef.value?.focus()
}
}

onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})

onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})

// Columns that can be toggled (name is always visible)
const toggleableColumns = computed(() => props.columns.filter(col => col.id !== 'name'))

// Map column IDs to i18n keys
const columnLabelKey: Record<string, string> = {
name: 'filters.columns.name',
version: 'filters.columns.version',
description: 'filters.columns.description',
downloads: 'filters.columns.downloads',
updated: 'filters.columns.updated',
maintainers: 'filters.columns.maintainers',
keywords: 'filters.columns.keywords',
qualityScore: 'filters.columns.quality_score',
popularityScore: 'filters.columns.popularity_score',
maintenanceScore: 'filters.columns.maintenance_score',
combinedScore: 'filters.columns.combined_score',
security: 'filters.columns.security',
}

function getColumnLabel(id: string): string {
const key = columnLabelKey[id]
return key ? $t(key) : id
}

function handleReset() {
emit('reset')
isOpen.value = false
}
</script>

<template>
<div class="relative">
<button
ref="buttonRef"
type="button"
class="btn-ghost inline-flex items-center gap-1.5 px-3 py-1.5 border border-border rounded-md hover:border-border-hover focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
:aria-expanded="isOpen"
aria-haspopup="true"
:aria-controls="menuId"
@click.stop="isOpen = !isOpen"
>
<span class="i-carbon-column w-4 h-4" aria-hidden="true" />
<span class="font-mono text-sm">{{ $t('filters.columns.title') }}</span>
</button>

<Transition name="dropdown">
<div
v-if="isOpen"
ref="menuRef"
:id="menuId"
class="absolute right-0 mt-2 w-56 bg-bg-subtle border border-border rounded-lg shadow-lg z-20"
role="group"
:aria-label="$t('filters.columns.show')"
>
<div class="py-1">
<div
class="px-3 py-2 text-xs font-mono text-fg-subtle uppercase tracking-wider border-b border-border"
aria-hidden="true"
>
{{ $t('filters.columns.show') }}
</div>

<div class="py-1 max-h-64 overflow-y-auto">
<label
v-for="column in toggleableColumns"
:key="column.id"
class="flex items-center px-3 py-2 transition-colors duration-200"
:class="
column.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-bg-muted cursor-pointer'
"
>
<input
type="checkbox"
:checked="column.visible"
:disabled="column.disabled"
:aria-describedby="column.disabled ? `${column.id}-disabled-reason` : undefined"
class="w-4 h-4 accent-fg bg-bg-muted border-border rounded disabled:opacity-50"
@change="!column.disabled && emit('toggle', column.id)"
/>
<span class="ml-2 text-sm text-fg-muted font-mono flex-1">
{{ getColumnLabel(column.id) }}
</span>
<span
v-if="column.disabled"
:id="`${column.id}-disabled-reason`"
class="text-xs text-fg-subtle italic"
>
{{ $t('filters.columns.coming_soon') }}
</span>
</label>
</div>

<div class="border-t border-border py-1">
<button
type="button"
class="w-full px-3 py-2 text-left text-sm font-mono text-fg-muted hover:bg-bg-muted hover:text-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset"
@click="handleReset"
>
{{ $t('filters.columns.reset') }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>

<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
}

.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
61 changes: 61 additions & 0 deletions app/components/FilterChips.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { FilterChip } from '#shared/types/preferences'

defineProps<{
chips: FilterChip[]
}>()

const emit = defineEmits<{
remove: [chip: FilterChip]
clearAll: []
}>()
</script>

<template>
<div v-if="chips.length > 0" class="flex flex-wrap items-center gap-2">
<TransitionGroup name="chip">
<span v-for="chip in chips" :key="chip.id" class="tag gap-1">
<span class="text-fg-subtle text-xs">{{ chip.label }}:</span>
<span class="max-w-32 truncate">{{
Array.isArray(chip.value) ? chip.value.join(', ') : chip.value
}}</span>
<button
type="button"
class="ml-0.5 hover:text-fg rounded-full p-0.5 transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:aria-label="$t('filters.remove_filter', { label: chip.label })"
@click="emit('remove', chip)"
>
<span class="i-carbon-close w-3 h-3" aria-hidden="true" />
</button>
</span>
</TransitionGroup>

<button
v-if="chips.length > 1"
type="button"
class="text-sm text-fg-subtle hover:text-fg underline transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2"
@click="emit('clearAll')"
>
{{ $t('filters.clear_all') }}
</button>
</div>
</template>

<style scoped>
.chip-enter-active,
.chip-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}

.chip-enter-from,
.chip-leave-to {
opacity: 0;
transform: scale(0.8);
}

.chip-move {
transition: transform 0.2s ease;
}
</style>
Loading