Begin collections implementation

This commit is contained in:
Prospector committed 2025-02-21 14:17:28 -08:00
1 parent 54f408dc6c
commit adf213f32a
17 files changed
+463 -42

No files matched your search

@@ -0,0 +1,111 @@
<template>
<Teleport to="#sidebar-teleport-target">
<CollectionSidebarDescription v-if="collection" :collection="collection" class="project-sidebar-section" />
<CollectionSidebarCurator v-if="curator" :user="curator" :link="`/user/${curator.id}`" class="project-sidebar-section" />
<CollectionSidebarDetails v-if="collection" :collection="collection" class="project-sidebar-section" />
</Teleport>
<div v-if="collection" class="p-6 flex flex-col gap-4">
<InstanceIndicator :instance="instance" />
<CollectionHeader :collection="collection">
<template #actions>
<ButtonStyled v-if="themeStore.devMode" circular type="transparent" size="large">
<OverflowMenu
:options="[
{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode },
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</CollectionHeader>
<div v-if="projects">
<ProjectsList :projects="projects" :project-link="(project) => `/project/${project.id}${instanceQueryAppendage}`" :experimental-colors="themeStore.featureFlags.project_card_background">
<template #project-actions="{ project }">
<ProjectCardActions :instance="instance" :instance-content="instanceContent" :project="project" />
</template>
</ProjectsList>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications.js'
import {
ProjectsList,
ButtonStyled,
commonMessages,
OverflowMenu,
CollectionHeader, CollectionSidebarCurator, CollectionSidebarDescription, CollectionSidebarDetails
} from '@modrinth/ui'
import { ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
import { useVIntl } from '@vintl/vintl'
import { useFetch } from '@/helpers/fetch'
import type { User, Project, Collection } from '@modrinth/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
import { useInstanceContext } from '@/composables/instance-context'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import ProjectCardActions from '@/components/ui/ProjectCardActions.vue'
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const { formatMessage } = useVIntl()
const collection: Ref<Collection | null> = ref(null)
const curator: Ref<User | null> = ref(null)
const projects: Ref<Project[]> = ref([])
async function fetchCollection() {
collection.value = await useFetch(`https://api.modrinth.com/v3/collection/${route.params.id}`).catch(handleError)
if (!collection.value) {
return;
}
[ projects.value, curator.value ] = await Promise.all([
useFetch(`https://api.modrinth.com/v2/projects?ids=${encodeURIComponent(JSON.stringify(collection.value.projects))}`),
useFetch(`https://api.modrinth.com/v2/user/${collection.value.user}`).catch(handleError),
])
breadcrumbs.setContext({ name: 'Collection', link: `/collection/${collection.value.name}` })
breadcrumbs.setName('Collection', collection.value.name)
}
await fetchCollection()
const { instance, instanceContent, instanceQueryAppendage } = await useInstanceContext()
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/collection')) {
await fetchCollection()
}
},
)
const themeStore = useTheming()
async function copyId() {
if (collection.value) {
await navigator.clipboard.writeText(String(collection.value.id));
}
}
</script>
<style scoped lang="scss">
.project-sidebar-section {
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
}
.project-sidebar-section:not(:last-child) {
@apply border-b-[1px];
}
</style>
@@ -0,0 +1,3 @@
import Index from './Index.vue'
export { Index }
@@ -6,7 +6,7 @@
<InstanceIndicator :instance="instance" />
<OrganizationHeader :organization="organization" :download-count="sumDownloads" :project-count="projects.length">
<template #actions>
<ButtonStyled circular type="transparent" size="large">
<ButtonStyled v-if="themeStore.devMode" circular type="transparent" size="large">
<OverflowMenu
:options="[
{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode },
+10 -5
View File
@@ -7,6 +7,7 @@
:download-count="sumDownloads"
class="project-sidebar-section"
/>
<UserSidebarCollections :collections="collections" :link="(collection: Collection) => `/collection/${collection.id}${instanceQueryAppendage}`" class="project-sidebar-section" />
</Teleport>
<div v-if="user" class="p-6 flex flex-col gap-4">
<InstanceIndicator :instance="instance" />
@@ -54,12 +55,12 @@ import {
commonMessages,
OverflowMenu,
UserHeader,
UserSidebarBadges
UserSidebarBadges, UserSidebarCollections
} from '@modrinth/ui'
import { ReportIcon, ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
import { useVIntl } from '@vintl/vintl'
import { useFetch } from '@/helpers/fetch'
import type { User, Project, Organization } from '@modrinth/utils'
import type { User, Project, Organization, Collection } from '@modrinth/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
import { useInstanceContext } from '@/composables/instance-context'
@@ -73,11 +74,15 @@ const { formatMessage } = useVIntl()
const user: Ref<User | null> = ref(null)
const projects: Ref<Project[]> = ref([])
const organizations: Ref<Organization[]> = ref([])
const collections: Ref<Collection[]> = ref([])
async function fetchUser() {
user.value = await useFetch(`https://api.modrinth.com/v2/user/${route.params.id}`).catch(handleError)
projects.value = await useFetch(`https://api.modrinth.com/v2/user/${route.params.id}/projects`).catch(handleError)
organizations.value = await useFetch(`https://api.modrinth.com/v3/user/${route.params.id}/organizations`).catch(handleError)
[ user.value, projects.value, organizations.value, collections.value ] = await Promise.all([
useFetch(`https://api.modrinth.com/v2/user/${route.params.id}`).catch(handleError),
useFetch(`https://api.modrinth.com/v2/user/${route.params.id}/projects`).catch(handleError),
useFetch(`https://api.modrinth.com/v3/user/${route.params.id}/organizations`).catch(handleError),
useFetch(`https://api.modrinth.com/v3/user/${route.params.id}/collections`).catch(handleError)
])
if (!user.value) {
return;
+11
View File
@@ -3,6 +3,7 @@ import * as Pages from '@/pages'
import * as Project from '@/pages/project'
import * as User from '@/pages/user'
import * as Organization from '@/pages/organization'
import * as Collection from '@/pages/collection'
import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library'
@@ -122,6 +123,16 @@ export default new createRouter({
breadcrumb: [{ name: '?Organization', link: '/organization/{id}' }],
},
},
{
path: '/collection/:id',
name: 'Collection',
component: Collection.Index,
props: true,
meta: {
useContext: true,
breadcrumb: [{ name: '?Collection', link: '/collection/{id}' }],
},
},
{
path: '/instance/:id',
name: 'Instance',
+16 -12
View File
@@ -2,21 +2,29 @@
<span v-if="to === undefined" v-bind="$attrs">
<slot />
</span>
<router-link v-else-if="link.type === 'router'" :to="link.destination" v-bind="$attrs" class="has-link has-router-link" @click="link.callback">
<router-link
v-else-if="link.type === 'router'"
:to="link.destination"
v-bind="$attrs"
class="has-link has-router-link"
@click="link.callback"
>
<slot />
</router-link>
<a v-else-if="link.type === 'external'" :href="link.destination" v-bind="$attrs" class="has-link has-external-link" @click="link.callback">
<a
v-else-if="link.type === 'external'"
:href="link.destination"
v-bind="$attrs"
class="has-link has-external-link"
@click="link.callback"
>
<slot />
</a>
</template>
<script setup lang="ts">
import { computed, type Ref } from 'vue'
import {
asLink,
type Link,
type Linkish
} from '../../utils/link'
import { asLink, type Link, type Linkish } from '../../utils/link'
const props = defineProps<{
to?: Linkish
@@ -26,9 +34,5 @@ defineOptions({
inheritAttrs: false,
})
const link: Ref<Link> = computed(() => {
const linkTest = asLink(props.to as Link)
console.log(linkTest)
return linkTest
})
const link: Ref<Link> = computed(() => asLink(props.to as Link))
</script>
@@ -0,0 +1,58 @@
<template>
<ContentPageHeader>
<template #icon>
<Avatar :src="collection.icon_url" :alt="collection.name" size="64px" />
</template>
<template #title>
{{ collection.name }}
</template>
<template #stats>
<div
v-if="collection.projects.length > 0"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<CollectionStatusBadge :status="collection.status" />
</div>
<div
v-if="collection.projects.length > 0"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<BoxIcon class="h-5 w-5 text-secondary" />
{{ formatMessage(commonMessages.projectsStat, { count: formatNumber(collection.projects.length, false) }) }}
</div>
<div
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(collection.updated),
time: new Date(collection.updated),
})
"
class="flex items-center gap-2 font-semibold"
>
<HistoryIcon class="h-5 w-5 text-secondary" />
{{ formatMessage(commonMessages.updatedDate, { date: dayjs(collection.updated).fromNow() }) }}
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</template>
<script setup lang="ts">
import { GlobeIcon, BoxIcon, HistoryIcon } from '@modrinth/assets'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import { type Collection, formatNumber } from '@modrinth/utils'
import { commonMessages } from '../../utils/common-messages'
import dayjs from 'dayjs'
import { useVIntl } from '@vintl/vintl'
import ProjectStatusBadge from '../project/ProjectStatusBadge.vue'
import CollectionStatusBadge from './CollectionStatusBadge.vue'
const { formatMessage } = useVIntl()
defineProps<{
collection: Collection
}>()
</script>
@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div class="flex flex-col gap-3 font-semibold">
<AutoLink
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="linkifiedLink"
>
<Avatar :src="user.avatar_url" :alt="user.username" size="32px" circle />
<div class="flex flex-col">
<span class="flex flex-row flex-nowrap items-center gap-1 group-hover:underline">
{{ user.username }}
<ExternalIcon v-if="linkifiedLink.type === 'external'" />
</span>
</div>
</AutoLink>
</div>
</div>
</template>
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl'
import Avatar from '../base/Avatar.vue'
import AutoLink from '../base/AutoLink.vue'
import type { User } from '@modrinth/utils'
import { asLink, type Linkish } from '../../utils/link'
import { computed } from 'vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
user: User
link: Linkish
}>()
const messages = defineMessages({
title: {
id: 'collection.curator.title',
defaultMessage: 'Curator',
},
})
const linkifiedLink = computed(() => asLink(props.link))
</script>
@@ -0,0 +1,25 @@
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div>
{{ collection.description }}
</div>
</div>
</template>
<script setup lang="ts">
import { useVIntl, defineMessages } from '@vintl/vintl'
import type { Collection } from '@modrinth/utils'
const { formatMessage } = useVIntl()
defineProps<{
collection: Collection
}>()
const messages = defineMessages({
title: {
id: 'collection.description.title',
defaultMessage: 'Description',
},
})
</script>
@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div class="flex flex-col gap-3 font-semibold [&>div]:flex [&>div]:gap-2 [&>div]:items-center">
<div
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(collection.created),
time: new Date(collection.created),
})
">
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(commonMessages.createdAgoLabel, { ago: dayjs(collection.created).fromNow() }) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CalendarIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl'
import dayjs from 'dayjs'
import type { Collection } from '@modrinth/utils'
import { commonMessages } from '../../utils/common-messages'
const { formatMessage } = useVIntl()
defineProps<{
collection: Collection
}>()
const messages = defineMessages({
title: {
id: 'collection.details.title',
defaultMessage: 'Details',
},
})
</script>
@@ -0,0 +1,48 @@
<template>
<Badge :icon="metadata.icon" :formatted-name="metadata.formattedName" />
</template>
<script setup lang="ts">
import {
LockIcon,
GlobeIcon,
LinkIcon,
UnknownIcon,
} from '@modrinth/assets'
import { useVIntl, type MessageDescriptor } from '@vintl/vintl'
import type { Component } from 'vue'
import { computed } from 'vue'
import Badge from '../base/SimpleBadge.vue'
import type { CollectionStatus } from '@modrinth/utils'
import { commonMessages } from '../../utils/common-messages'
const props = defineProps<{
status: CollectionStatus
}>()
const { formatMessage } = useVIntl()
const metadata = computed(() => ({
icon: statusMetadata[props.status]?.icon ?? statusMetadata.unknown.icon,
formattedName: formatMessage(statusMetadata[props.status]?.message ?? props.status),
}))
const statusMetadata: Record<CollectionStatus, { icon?: Component; message: MessageDescriptor }> = {
listed: {
icon: GlobeIcon,
message: commonMessages.publicLabel
},
unlisted: {
icon: LinkIcon,
message: commonMessages.unlistedLabel
},
private: {
icon: LockIcon,
message: commonMessages.privateLabel
},
unknown: {
icon: UnknownIcon,
message: commonMessages.unknownLabel
},
}
</script>
+8
View File
@@ -104,8 +104,16 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
export { default as UserBadge } from './user/UserBadge.vue'
export { default as UserHeader } from './user/UserHeader.vue'
export { default as UserSidebarBadges } from './user/UserSidebarBadges.vue'
export { default as UserSidebarCollections } from './user/UserSidebarCollections.vue'
export { default as UserSidebarOrganizations } from './user/UserSidebarOrganizations.vue'
// Organization
export { default as OrganizationHeader } from './organization/OrganizationHeader.vue'
export { default as OrganizationSidebarMembers } from './organization/OrganizationSidebarMembers.vue'
// Collection
export { default as CollectionHeader } from './collection/CollectionHeader.vue'
export { default as CollectionSidebarCurator } from './collection/CollectionSidebarCurator.vue'
export { default as CollectionSidebarDescription } from './collection/CollectionSidebarDescription.vue'
export { default as CollectionSidebarDetails } from './collection/CollectionSidebarDetails.vue'
export { default as CollectionStatusBadge } from './collection/CollectionStatusBadge.vue'
@@ -19,6 +19,7 @@ import type { Component } from 'vue'
import { computed } from 'vue'
import Badge from '../base/SimpleBadge.vue'
import type { ProjectStatus } from '@modrinth/utils'
import { commonMessages } from '../../utils/common-messages'
const props = defineProps<{
status: ProjectStatus
@@ -34,17 +35,11 @@ const metadata = computed(() => ({
const statusMetadata: Record<ProjectStatus, { icon?: Component; message: MessageDescriptor }> = {
approved: {
icon: GlobeIcon,
message: defineMessage({
id: 'project.visibility.public',
defaultMessage: 'Public',
}),
message: commonMessages.publicLabel,
},
unlisted: {
icon: LinkIcon,
message: defineMessage({
id: 'project.visibility.unlisted',
defaultMessage: 'Unlisted',
}),
message: commonMessages.unlistedLabel,
},
withheld: {
icon: LinkIcon,
@@ -55,10 +50,7 @@ const statusMetadata: Record<ProjectStatus, { icon?: Component; message: Message
},
private: {
icon: LockIcon,
message: defineMessage({
id: 'project.visibility.private',
defaultMessage: 'Private',
}),
message: commonMessages.privateLabel,
},
scheduled: {
icon: CalendarIcon,
@@ -83,10 +75,7 @@ const statusMetadata: Record<ProjectStatus, { icon?: Component; message: Message
},
rejected: {
icon: XIcon,
message: defineMessage({
id: 'project.visibility.rejected',
defaultMessage: 'Rejected',
}),
message: commonMessages.rejectedLabel,
},
processing: {
icon: UpdatedIcon,
@@ -97,10 +86,7 @@ const statusMetadata: Record<ProjectStatus, { icon?: Component; message: Message
},
unknown: {
icon: UnknownIcon,
message: defineMessage({
id: 'project.visibility.unknown',
defaultMessage: 'Unknown',
}),
message: commonMessages.unknownLabel,
},
}
</script>
@@ -1,8 +1,7 @@
<template>
<template v-if="projects.length > 0">
<div class="mb-3">
<NavTabs
v-if="availableProjectTypes.length > 1"
:tabs="[
{
label: formatMessage(ANY_PROJECT_TYPE_MESSAGE),
@@ -19,7 +18,6 @@
]"
class="mb-3"
/>
</div>
<div class="mb-3 flex gap-2">
<div class="iconified-input flex-1">
<SearchIcon />
@@ -0,0 +1,62 @@
<template>
<div v-if="collections.length > 0" class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div class="flex flex-col gap-2 place-items-center">
<Tooltip
v-for="collection in collectionsSorted"
:key="`user-collection-${collection.id}`"
class="w-full"
placement="right"
>
<AutoLink
:to="link(collection)"
class="flex gap-2 group overflow-hidden"
>
<Avatar :src="collection.icon_url" :alt="'Icon for ' + collection.name" size="3rem" />
<div class="flex flex-col gap-2">
<span class="items-center text-primary font-bold gap-2 line-clamp-1 text-ellipsis group-hover:underline">
{{ collection.name }}
</span>
<span class="text-secondary font-medium flex items-center gap-1">
<BoxIcon /> {{ formatMessage(commonMessages.projectsStat, { count: formatNumber(collection.projects.length, false) }) }}
</span>
</div>
</AutoLink>
<template #popper>
<div class="flex flex-col p-1 dark">
<p class="text-sm font-bold text-contrast m-0 max-w-80">{{ collection.name }}</p>
<p class="text-sm text-primary font-semibold m-0 max-w-80">{{ collection.description }}</p>
</div>
</template>
</Tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { useVIntl, defineMessages } from '@vintl/vintl'
import { computed } from 'vue'
import Avatar from '../base/Avatar.vue'
import AutoLink from '../base/AutoLink.vue'
import type { Collection } from '@modrinth/utils'
import type { Linkish } from '../../utils/link'
import { commonMessages } from '../../utils/common-messages'
import { formatNumber } from '@modrinth/utils'
import { BoxIcon } from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
collections: Collection[],
link: (collection: Collection) => Linkish
}>()
const collectionsSorted = computed(() => props.collections.slice().sort((a, b) => a.name.localeCompare(b.name)))
const messages = defineMessages({
title: {
id: 'user.collections.title',
defaultMessage: 'Collections',
},
})
</script>
+5 -1
View File
@@ -110,7 +110,7 @@ export const commonMessages = defineMessages({
defaultMessage: '{count} {count, plural, one {member} other {members}}',
},
privateLabel: {
id: 'collection.label.private',
id: 'label.private',
defaultMessage: 'Private',
},
publicLabel: {
@@ -189,6 +189,10 @@ export const commonMessages = defineMessages({
id: 'label.unlisted',
defaultMessage: 'Unlisted',
},
unknownLabel: {
id: 'label.unknown',
defaultMessage: 'Unknown',
},
uploadImageButton: {
id: 'button.upload-image',
defaultMessage: 'Upload image',
+15
View File
@@ -335,3 +335,18 @@ export type OrganizationMember = {
payouts_split: number
ordering: number
}
export type Collection = {
id: ModrinthId
user: User
name: string
description: string
icon_url: string
color: number
status: CollectionStatus
created: string
updated: string
projects: ModrinthId[]
}
export type CollectionStatus = 'listed' | 'unlisted' | 'private' | 'unknown'