feat: fixed the collections page sorting logic and add URL persistence (#5375)

* "feat(collections): fix sorting logic and add URL persistence"

* fix(navigation): use replaceState for project filters to prevent history pollution

* Revert "fix(navigation): use replaceState for project filters to prevent history pollution"

This reverts commit 3924855fafcf2921056e31b7606a143de01ed6a6.

* fix: lint + devin

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Mingxuan Ding
2026-03-10 04:12:44 +08:00
committed by GitHub
parent 5a51a755eb
commit 01b8ee6909
2 changed files with 120 additions and 46 deletions

View File

@@ -578,6 +578,18 @@
"dashboard.collections.button.create-new": { "dashboard.collections.button.create-new": {
"message": "Create new" "message": "Create new"
}, },
"dashboard.collections.empty.get-started-hint": {
"message": "Create your first collection to get started!"
},
"dashboard.collections.empty.no-collections": {
"message": "You don't have any collections yet"
},
"dashboard.collections.empty.no-match": {
"message": "No collections match your search"
},
"dashboard.collections.empty.no-match-hint": {
"message": "Try adjusting your filters or search terms."
},
"dashboard.collections.label.projects-count": { "dashboard.collections.label.projects-count": {
"message": "{count, plural, one {{count} project} other {{count} projects}}" "message": "{count, plural, one {{count} project} other {{count} projects}}"
}, },

View File

@@ -2,24 +2,48 @@
<div class="universal-card"> <div class="universal-card">
<CollectionCreateModal ref="modal_creation" /> <CollectionCreateModal ref="modal_creation" />
<h2 class="text-2xl">{{ formatMessage(commonMessages.collectionsLabel) }}</h2> <h2 class="text-2xl">{{ formatMessage(commonMessages.collectionsLabel) }}</h2>
<div class="search-row"> <div class="mb-3 flex flex-col gap-3">
<div class="flex-grow"> <label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label>
<label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label> <StyledInput
<StyledInput id="search-input"
id="search-input" v-model="filterQuery"
v-model="filterQuery" :icon="SearchIcon"
:icon="SearchIcon" type="text"
type="text" clearable
clearable placeholder="Search collections..."
placeholder="Search collections..." wrapper-class="w-full"
wrapper-class="w-full" input-class="!h-12"
input-class="h-8" />
/>
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="sortBy"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="['updated', 'created', 'name']"
:display-name="
(option) =>
option === 'updated'
? 'Recently Updated'
: option === 'created'
? 'Recently Created'
: 'Name (A-Z)'
"
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<Button
color="primary"
class="ml-auto"
@click="(event) => $refs.modal_creation.show(event)"
>
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNewButton) }}
</Button>
</div> </div>
<Button color="primary" @click="(event) => $refs.modal_creation.show(event)">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNewButton) }}
</Button>
</div> </div>
<div class="collections-grid"> <div class="collections-grid">
<nuxt-link <nuxt-link
@@ -50,9 +74,7 @@
</div> </div>
</nuxt-link> </nuxt-link>
<nuxt-link <nuxt-link
v-for="collection in orderedCollections.sort( v-for="collection in orderedCollections"
(a, b) => new Date(b.created) - new Date(a.created),
)"
:key="collection.id" :key="collection.id"
:to="`/collection/${collection.id}`" :to="`/collection/${collection.id}`"
class="universal-card recessed collection" class="universal-card recessed collection"
@@ -95,6 +117,25 @@
</nuxt-link> </nuxt-link>
</div> </div>
</div> </div>
<div v-if="orderedCollections.length === 0" class="empty-state-container">
<div class="py-12 text-center">
<BoxIcon class="mx-auto h-12 w-12 text-secondary opacity-50" aria-hidden="true" />
<p class="mt-4 text-lg font-medium text-contrast">
{{
filterQuery
? formatMessage(messages.emptyNoMatch)
: formatMessage(messages.emptyNoCollections)
}}
</p>
<p class="text-sm text-secondary">
{{
filterQuery
? formatMessage(messages.emptyNoMatchHint)
: formatMessage(messages.emptyGetStartedHint)
}}
</p>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
@@ -106,7 +147,15 @@ import {
SearchIcon, SearchIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, Button, commonMessages, defineMessages, StyledInput, useVIntl } from '@modrinth/ui' import {
Avatar,
Button,
commonMessages,
defineMessages,
DropdownSelect,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue' import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
@@ -134,6 +183,22 @@ const messages = defineMessages({
id: 'dashboard.collections.label.search-input', id: 'dashboard.collections.label.search-input',
defaultMessage: 'Search your collections', defaultMessage: 'Search your collections',
}, },
emptyNoMatch: {
id: 'dashboard.collections.empty.no-match',
defaultMessage: 'No collections match your search',
},
emptyNoCollections: {
id: 'dashboard.collections.empty.no-collections',
defaultMessage: "You don't have any collections yet",
},
emptyNoMatchHint: {
id: 'dashboard.collections.empty.no-match-hint',
defaultMessage: 'Try adjusting your filters or search terms.',
},
emptyGetStartedHint: {
id: 'dashboard.collections.empty.get-started-hint',
defaultMessage: 'Create your first collection to get started!',
},
}) })
definePageMeta({ definePageMeta({
@@ -157,19 +222,33 @@ const { data: collections } = await useAsyncData(`user/${auth.value.user.id}/col
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 }), useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 }),
) )
const route = useNativeRoute()
const router = useNativeRouter()
const validSortOptions = ['updated', 'created', 'name']
const sortBy = ref(validSortOptions.includes(route.query.s) ? route.query.s : 'updated')
const orderedCollections = computed(() => { const orderedCollections = computed(() => {
if (!collections.value) return [] if (!collections.value) return []
return [...collections.value] // copy to avoid in-place mutation (no side effects) return [...collections.value]
.filter(
(c) => !filterQuery.value || c.name.toLowerCase().includes(filterQuery.value.toLowerCase()),
)
.sort((a, b) => { .sort((a, b) => {
const aUpdated = new Date(a.updated) if (sortBy.value === 'name') return a.name.localeCompare(b.name)
const bUpdated = new Date(b.updated) if (sortBy.value === 'created') return new Date(b.created) - new Date(a.created)
return bUpdated - aUpdated return new Date(b.updated) - new Date(a.updated)
})
.filter((collection) => {
if (!filterQuery.value) return true
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase())
}) })
}) })
watch(sortBy, (newVal) => {
router.replace({
path: route.path,
query: {
...route.query,
s: newVal,
},
})
})
</script> </script>
<style lang="scss"> <style lang="scss">
.collections-grid { .collections-grid {
@@ -225,21 +304,4 @@ const orderedCollections = computed(() => {
} }
} }
} }
.search-row {
margin-bottom: var(--gap-lg);
display: flex;
align-items: center;
gap: var(--gap-lg) var(--gap-sm);
flex-wrap: wrap;
justify-content: center;
.iconified-input {
flex-grow: 1;
input {
height: 2rem;
}
}
}
</style> </style>