update tags page + sort project types consistently (#7044)

This commit is contained in:
Prospector
2026-08-07 13:19:36 -07:00
committed by GitHub
parent a71a361e0f
commit 75335647ee
24 changed files with 452 additions and 470 deletions
+22 -22
View File
@@ -138,22 +138,6 @@
<BracesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
</ButtonLink>
<ButtonLink
type="quiet"
to="/discover/modpacks"
:class="
route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')
? (route.name === 'discover-modpacks'
? 'main-nav-primary'
: 'main-nav-secondary') === 'main-nav-primary'
? '!bg-[var(--color-button-bg-selected)] !text-[var(--color-button-text-selected)] [&>svg]:!text-[var(--color-button-text-selected)]'
: '!bg-[var(--color-button-bg)] !text-contrast'
: ''
"
>
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</ButtonLink>
<ButtonLink
type="quiet"
to="/discover/shaders"
@@ -170,6 +154,22 @@
<GlassesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
</ButtonLink>
<ButtonLink
type="quiet"
to="/discover/modpacks"
:class="
route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')
? (route.name === 'discover-modpacks'
? 'main-nav-primary'
: 'main-nav-secondary') === 'main-nav-primary'
? '!bg-[var(--color-button-bg-selected)] !text-[var(--color-button-text-selected)] [&>svg]:!text-[var(--color-button-text-selected)]'
: '!bg-[var(--color-button-bg)] !text-contrast'
: ''
"
>
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</ButtonLink>
<ButtonLink
type="quiet"
to="/discover/plugins"
@@ -1215,8 +1215,8 @@ const navRoutes = computed(() => [
href: '/discover/mods',
},
{
label: formatMessage(getProjectTypeMessage('plugin', true)),
href: '/discover/plugins',
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
href: '/discover/resourcepacks',
},
{
label: formatMessage(getProjectTypeMessage('datapack', true)),
@@ -1226,14 +1226,14 @@ const navRoutes = computed(() => [
label: formatMessage(getProjectTypeMessage('shader', true)),
href: '/discover/shaders',
},
{
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
href: '/discover/resourcepacks',
},
{
label: formatMessage(getProjectTypeMessage('modpack', true)),
href: '/discover/modpacks',
},
{
label: formatMessage(getProjectTypeMessage('plugin', true)),
href: '/discover/plugins',
},
{
label: formatMessage(getProjectTypeMessage('server', true)),
href: '/discover/servers',
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "Hervorgehobene Tags"
},
"project.settings.tags.featured-tags-description": {
"message": "Du kannst bis zu 3 deiner relevantesten Tags als hervorheben. Andere Tags werden möglicherweise hervorgehoben, falls du nicht alle 3 auswählst."
},
"project.settings.tags.features-description": {
"message": "Wähle alle Funktionen aus, die dein {type} nutzt."
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Wähle mindestens eine Kategorie aus, um eine Kategorie hervorzuheben."
},
"project.settings.tags.tagging-importance-description": {
"message": "Genaue Tags sind wichtig, damit Nutzer dein {type} leichter finden können. Achte darauf, alle zutreffenden Tags auszuwählen."
},
"project.settings.tags.title": {
"message": "Tags"
},
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "Hervorgehobene Tags"
},
"project.settings.tags.featured-tags-description": {
"message": "Du kannst bis zu 3 deiner relevantesten Tags als hervorheben. Andere Tags werden möglicherweise hervorgehoben, falls du nicht alle 3 auswählst."
},
"project.settings.tags.features-description": {
"message": "Wähle alle Funktionen aus, die dein {type} nutzt."
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Wähle mindestens eine Kategorie aus, um eine Kategorie hervorzuheben."
},
"project.settings.tags.tagging-importance-description": {
"message": "Genaue Tags sind wichtig, damit Nutzer dein {type} leichter finden können. Achte darauf, alle zutreffenden Tags auszuwählen."
},
"project.settings.tags.title": {
"message": "Tags"
},
+17 -5
View File
@@ -3929,12 +3929,27 @@
"project.settings.tags.featured-tags": {
"message": "Featured tags"
},
"project.settings.tags.featured-tags-description": {
"message": "You can feature up to 3 of your most relevant tags. Other tags may be promoted to featured if you do not select all 3."
"project.settings.tags.featured-tags-required": {
"message": "You must have at least one featured tag."
},
"project.settings.tags.featured-tags-select-description": {
"message": "Select your most relevant tags. These are displayed before the rest of your tags."
},
"project.settings.tags.features-description": {
"message": "Select all of the features that your {type} makes use of."
},
"project.settings.tags.group-title.categories": {
"message": "{showType, select, yes {{types} categories} other {Categories}}"
},
"project.settings.tags.group-title.features": {
"message": "{showType, select, yes {{types} features} other {Features}}"
},
"project.settings.tags.group-title.performance-impact": {
"message": "{showType, select, yes {{types} performance impact} other {Performance impact}}"
},
"project.settings.tags.group-title.resolutions": {
"message": "{showType, select, yes {{types} resolutions} other {Resolutions}}"
},
"project.settings.tags.multiple-resolution-tags-warning": {
"message": "You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag."
},
@@ -3947,9 +3962,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Select at least one category in order to feature a category."
},
"project.settings.tags.tagging-importance-description": {
"message": "Accurate tagging is important to help people find your {type}. Make sure to select all tags that apply."
},
"project.settings.tags.title": {
"message": "Tags"
},
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "Etiquetas destacadas"
},
"project.settings.tags.featured-tags-description": {
"message": "Puedes destacar hasta 3 de tus etiquetas más relevantes. Otras etiquetas pueden ser promovidas a destacadas si no seleccionas las 3 completas."
},
"project.settings.tags.features-description": {
"message": "Selecciona todas las características que tu {type} usa."
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Selecciona al menos una categoría para destacar una categoría."
},
"project.settings.tags.tagging-importance-description": {
"message": "Seleccionar etiquetas relevantes es importante para ayudar a la gente a encontrar tu {type}. Asegúrate de seleccionar todas las etiquetas que apliquen."
},
"project.settings.tags.title": {
"message": "Etiquetas"
},
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "Tags mis en avant"
},
"project.settings.tags.featured-tags-description": {
"message": "Vous pouvez mettre en avant jusqu'à 3 de vos tags les plus pertinents. D'autres tags peuvent être mis en avant si vous ne sélectionnez pas les 3."
},
"project.settings.tags.features-description": {
"message": "Sélectionnez toutes les fonctionnalités que votre {type} utilise."
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Sélectionnez au moins une catégorie afin de pouvoir mettre en avant une catégorie."
},
"project.settings.tags.tagging-importance-description": {
"message": "Un étiquetage correct est important pour aider les gens à trouver votre {type}. Faites en sorte de sélectionner tous les tags qui s'appliquent."
},
"project.settings.tags.title": {
"message": "Tags"
},
@@ -3641,9 +3641,6 @@
"project.settings.tags.featured-tags": {
"message": "Kiemelt címkék"
},
"project.settings.tags.featured-tags-description": {
"message": "Legfeljebb 3 legrelevánsabb címkét jelölhetsz ki kiemeltként. Ha nem választod ki mind a 3-at, akkor a többi címke is kiemeltként jelenhet meg."
},
"project.settings.tags.features-description": {
"message": "Jelöld ki az összes olyan funkciót, amelyet a {type} használ."
},
@@ -3659,9 +3656,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Válassz ki legalább egy kategóriát ahhoz, hogy kiemelj egy kategóriát."
},
"project.settings.tags.tagging-importance-description": {
"message": "A pontos címkézés fontos ahhoz, hogy a {type} könnyebben me. Feltétlenül válaszd ki az összes releváns címkét."
},
"project.settings.tags.title": {
"message": "Címkék"
},
@@ -3917,9 +3917,6 @@
"project.settings.tags.featured-tags": {
"message": "Tag in evidenza"
},
"project.settings.tags.featured-tags-description": {
"message": "Puoi mettere in evidenza fino a 3 tag. Altri tag potrebbero aggiungersi se non bastassero."
},
"project.settings.tags.multiple-resolution-tags-warning": {
"message": "Hai selezionato {count} tag di risoluzione ({tags}). I pacchetti di risorse dovrebbero averne solo uno."
},
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "추천 태그"
},
"project.settings.tags.featured-tags-description": {
"message": "가장 적합한 태그를 최대 3개까지 지정할 수 있습니다. 3개를 모두 선택하지 않으면 다른 태그가 추천 태그로 표시될 수 있습니다."
},
"project.settings.tags.features-description": {
"message": " {type}에서 사용하는 모든 기능을 선택하세요."
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "추천 카테고리를 지정하려면 적어도 하나의 카테고리를 선택해야 합니다."
},
"project.settings.tags.tagging-importance-description": {
"message": "정확한 태그 선택은 {type}을 쉽게 찾을 수 있도록 돕습니다. 관련된 모든 태그를 선택해 주세요."
},
"project.settings.tags.title": {
"message": "태그"
},
@@ -3926,9 +3926,6 @@
"project.settings.tags.featured-tags": {
"message": "Uitgelichte tags"
},
"project.settings.tags.featured-tags-description": {
"message": "Je kunt maximaal 3 van je meest relevante tags als uitgelichte tags selecteren. Als je niet alle 3 selecteert, kunnen andere tags als uitgelichte tags worden weergegeven."
},
"project.settings.tags.features-description": {
"message": "Selecteer alle functies waarvan je {type} gebruikmaakt."
},
@@ -3944,9 +3941,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Selecteer minstens één categorie om een categorie uit te lichten."
},
"project.settings.tags.tagging-importance-description": {
"message": "Nauwkeurige tags zijn belangrijk om mensen te helpen je {type} te vinden. Zorg ervoor dat je alle relevante tags selecteert."
},
"project.settings.tags.title": {
"message": "Tags"
},
@@ -3875,9 +3875,6 @@
"project.settings.tags.featured-tags": {
"message": "Wyróżnione tagi"
},
"project.settings.tags.featured-tags-description": {
"message": "Możesz wyróżnić 3 z najlepiej pasujących do Twojego projektu tagów. Inne tagi mogą zostać pokazane jako wyróżnione, jeżeli nie wybierzesz wszystkich trzech."
},
"project.settings.tags.features-description": {
"message": "Wszystkie funkcjonalności, których używa {type}"
},
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "Etiquetas em destaque"
},
"project.settings.tags.featured-tags-description": {
"message": "Você pode destacar até 3 das suas etiquetas mais relevantes. Outras etiquetas poderão ser promovidas a destaque caso você não selecione todas as 3."
},
"project.settings.tags.features-description": {
"message": "Selecione todas as funcionalidades que o seu {type} utiliza."
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Selecione pelo menos uma categoria para ela ser destacada."
},
"project.settings.tags.tagging-importance-description": {
"message": "Etiquetar corretamente é importante para ajudar as pessoas a encontrarem seu {type}. Certifique-se de selecionar todas as etiquetas aplicáveis."
},
"project.settings.tags.title": {
"message": "Etiquetas"
},
@@ -3920,9 +3920,6 @@
"project.settings.tags.featured-tags": {
"message": "Ключевые теги"
},
"project.settings.tags.featured-tags-description": {
"message": "Можно выбрать до 3 ключевых тегов. Если не выбрать все 3, то другие теги могут стать ключевыми."
},
"project.settings.tags.features-description": {
"message": "Выберите все особенности, которые использует ваш {type}"
},
@@ -3935,9 +3932,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Выберите хотя бы одну категорию, чтобы отобразить ее."
},
"project.settings.tags.tagging-importance-description": {
"message": "Аккуратная маркировка важна, чтобы помочь людям найти ваш {type}"
},
"project.settings.tags.title": {
"message": "Теги"
},
@@ -3926,9 +3926,6 @@
"project.settings.tags.featured-tags": {
"message": "Основні теґи"
},
"project.settings.tags.featured-tags-description": {
"message": "Ви можете обрати основними до 3 найбільш описових теґів. Інші теґи можуть стати основними, якщо не оберете всі 3."
},
"project.settings.tags.features-description": {
"message": "Оберіть увесь функціонал, який присутній у {type}."
},
@@ -3944,9 +3941,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "Виберіть хоча б одну категорію, щоб рекомендувати її."
},
"project.settings.tags.tagging-importance-description": {
"message": "Указання правильних теґів необхідне, щоб люди могли легше знайти ваш {type}. Переконайтеся, що ви обрали підхожі теґи."
},
"project.settings.tags.title": {
"message": "Теґи"
},
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "精选标签"
},
"project.settings.tags.featured-tags-description": {
"message": "你可以设置最多 3 个精选标签,标示出最相关的特点。如果没有选完 3 个标签,其他标签也可能提升为精选标签。"
},
"project.settings.tags.features-description": {
"message": "选择你的{type}作用于哪类特性。"
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "请先至少选择一个分类,再设置精选分类。"
},
"project.settings.tags.tagging-importance-description": {
"message": "准确选好标签,能极大帮助别人找到你的{type}。请确保选择所有合适的标签。"
},
"project.settings.tags.title": {
"message": "标签"
},
@@ -3929,9 +3929,6 @@
"project.settings.tags.featured-tags": {
"message": "精選標籤"
},
"project.settings.tags.featured-tags-description": {
"message": "你可以選擇最多 3 個最相關的標籤作為精選標籤。如果沒有選完 3 個標籤,系統可能會自動將其他標籤選為精選標籤。"
},
"project.settings.tags.features-description": {
"message": "請選擇你的{type}包含的功能。"
},
@@ -3947,9 +3944,6 @@
"project.settings.tags.select-at-least-one-category": {
"message": "請選擇至少一個類別再選擇精選標籤。"
},
"project.settings.tags.tagging-importance-description": {
"message": "精確的標籤對於協助使用者找到你的{type}非常重要。請務必選擇所有合適的標籤。"
},
"project.settings.tags.title": {
"message": "標籤"
},
@@ -1,174 +1,23 @@
<template>
<div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">{{ formatMessage(messages.tagsTitle) }}</span>
</h3>
</div>
<div
v-if="tooManyTagsWarning && !allTagsSelectedWarning"
class="my-2 flex items-center gap-1.5 text-orange"
>
<TriangleAlertIcon class="my-auto" />
{{ tooManyTagsWarning }}
</div>
<div v-if="multipleResolutionTagsWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ multipleResolutionTagsWarning }}
</div>
<div v-if="allTagsSelectedWarning" class="my-2 flex items-center gap-1.5 text-red">
<TriangleAlertIcon class="my-auto" />
<span>{{ allTagsSelectedWarning }}</span>
</div>
<p>
{{
formatMessage(messages.taggingImportanceDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</p>
<p
v-if="project.versions.length === 0 && projectV3?.minecraft_server == null"
class="known-errors"
>
{{ formatMessage(messages.uploadVersionFirst) }}
</p>
<template v-else>
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div class="label mb-3">
<h4>
<span class="label__title">{{ formatCategoryHeader(formatMessage, header) }}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
{{
formatMessage(messages.categoriesDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
<template v-else-if="header === 'features'">
{{
formatMessage(messages.featuresDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
<template v-else-if="header === 'resolutions'">
{{
formatMessage(messages.resolutionsDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
<template v-else-if="header === 'performance impact'">
{{
formatMessage(messages.performanceImpactDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
</span>
</div>
<div class="category-list input-div">
<Checkbox
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:model-value="current.selectedTags.includes(category)"
:description="formatCategory(formatMessage, category.name)"
class="category-selector"
@update:model-value="toggleCategory(category)"
>
<div class="category-selector__label">
<component
:is="getTagIcon(category.name)"
v-if="header !== 'resolutions' && getTagIcon(category.name)"
aria-hidden="true"
class="icon"
/>
<span aria-hidden="true">
<FormattedTag :tag="category.name" enforce-type="category" />
</span>
</div>
</Checkbox>
</div>
</template>
<div class="label">
<h4>
<span class="label__title"
><StarIcon /> {{ formatMessage(messages.featuredTags) }}</span
>
</h4>
<span class="label__description">{{
formatMessage(messages.featuredTagsDescription)
}}</span>
</div>
<p v-if="current.selectedTags.length < 1">
{{ formatMessage(messages.selectAtLeastOneCategory) }}
</p>
<div class="category-list input-div">
<Checkbox
v-for="category in current.selectedTags"
:key="`featured-category-${category.name}`"
class="category-selector"
:model-value="current.featuredTags.includes(category)"
:description="formatCategory(formatMessage, category.name)"
:disabled="current.featuredTags.length >= 3 && !current.featuredTags.includes(category)"
@update:model-value="toggleFeaturedCategory(category)"
>
<div class="category-selector__label">
<component
:is="getTagIcon(category.name)"
v-if="category.header !== 'resolutions' && getTagIcon(category.name)"
aria-hidden="true"
class="icon"
/>
<span aria-hidden="true">
<FormattedTag :tag="category.name" enforce-type="category" />
</span>
</div>
</Checkbox>
</div>
</template>
</section>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
@reset="reset"
@save="save"
/>
</div>
</template>
<script setup lang="ts">
import {
getCategoryIcon,
SERVER_CATEGORY_ICON_MAP,
StarIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import {
Admonition,
Checkbox,
ConfirmLeaveModal,
defineMessages,
formatCategory,
formatCategoryHeader,
formatProjectTypeSentence,
FormattedTag,
injectProjectPageContext,
type MessageDescriptor,
sortProjectTypes,
TagItem,
UnsavedChangesPopup,
usePageLeaveSafety,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType, sortedCategories } from '@modrinth/utils'
import { capitalizeString, sortedCategories } from '@modrinth/utils'
import { computed } from 'vue'
interface Category {
@@ -178,19 +27,30 @@ interface Category {
project_type: string
}
interface CategoryGroup {
id: string
title: string
description?: string
categories: Category[]
}
const MAX_FEATURED_TAGS = 3
const RESOLUTION_TAGS = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
const SHARED_CATEGORY_PROJECT_TYPES: Record<string, string> = {
plugin: 'mod',
datapack: 'mod',
}
const tags = useGeneratedState()
const { formatMessage, locale } = useVIntl()
const messages = defineMessages({
tagsTitle: {
title: {
id: 'project.settings.tags.title',
defaultMessage: 'Tags',
},
taggingImportanceDescription: {
id: 'project.settings.tags.tagging-importance-description',
defaultMessage:
'Accurate tagging is important to help people find your {type}. Make sure to select all tags that apply.',
},
uploadVersionFirst: {
id: 'project.settings.tags.upload-version-first',
defaultMessage: 'Please upload a version first in order to select tags!',
@@ -217,14 +77,18 @@ const messages = defineMessages({
defaultMessage: 'Featured tags',
},
featuredTagsDescription: {
id: 'project.settings.tags.featured-tags-description',
id: 'project.settings.tags.featured-tags-select-description',
defaultMessage:
'You can feature up to 3 of your most relevant tags. Other tags may be promoted to featured if you do not select all 3.',
'Select your most relevant tags. These are displayed before the rest of your tags.',
},
selectAtLeastOneCategory: {
id: 'project.settings.tags.select-at-least-one-category',
defaultMessage: 'Select at least one category in order to feature a category.',
},
featuredTagsRequired: {
id: 'project.settings.tags.featured-tags-required',
defaultMessage: 'You must have at least one featured tag.',
},
tooManyTagsServerHardWarning: {
id: 'project.settings.tags.too-many-tags-server-hard-warning',
defaultMessage:
@@ -252,72 +116,183 @@ const messages = defineMessages({
},
})
const groupTitleMessages: Record<string, MessageDescriptor> = defineMessages({
categories: {
id: 'project.settings.tags.group-title.categories',
defaultMessage: '{showType, select, yes {{types} categories} other {Categories}}',
},
features: {
id: 'project.settings.tags.group-title.features',
defaultMessage: '{showType, select, yes {{types} features} other {Features}}',
},
resolutions: {
id: 'project.settings.tags.group-title.resolutions',
defaultMessage: '{showType, select, yes {{types} resolutions} other {Resolutions}}',
},
'performance impact': {
id: 'project.settings.tags.group-title.performance-impact',
defaultMessage:
'{showType, select, yes {{types} performance impact} other {Performance impact}}',
},
})
const groupDescriptionMessages: Record<string, MessageDescriptor> = {
categories: messages.categoriesDescription,
features: messages.featuresDescription,
resolutions: messages.resolutionsDescription,
'performance impact': messages.performanceImpactDescription,
}
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
const formatCategoryName = (categoryName: string) => {
return formatCategory(formatMessage, categoryName)
}
const formatCategoryName = (categoryName: string) => formatCategory(formatMessage, categoryName)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const getTagIcon = (categoryName: string) => {
const iconName = isServerProject.value
? (SERVER_CATEGORY_ICON_MAP[categoryName] ?? categoryName)
: categoryName
return getCategoryIcon(iconName)
const canSelectTags = computed(() => project.value.versions.length > 0 || isServerProject.value)
const projectTypes = computed(() => {
if (isServerProject.value) {
return ['minecraft_java_server']
}
const types = projectV3.value?.project_types?.length
? projectV3.value.project_types
: [project.value.actualProjectType]
return sortProjectTypes(new Set(types))
})
const allCategories = computed(
() => sortedCategories(tags.value, formatCategoryName, locale.value) as Category[],
)
const projectTypesByCategoryList = computed(() => {
const lists = new Map<string, string[]>()
for (const projectType of projectTypes.value) {
const source = SHARED_CATEGORY_PROJECT_TYPES[projectType] ?? projectType
lists.set(source, [...(lists.get(source) ?? []), projectType])
}
return lists
})
const projectTypeListFormatter = computed(
() => new Intl.ListFormat(locale.value, { style: 'long', type: 'conjunction' }),
)
function formatProjectTypeName(type: string) {
return formatProjectTypeSentence(
formatMessage,
type === 'minecraft_java_server' ? 'server' : type,
)
}
const matchesProjectType = (x: Category) => {
if (isServerProject.value) {
return x.project_type === 'minecraft_java_server'
} else {
return x.project_type === project.value.actualProjectType
function formatGroupTitle(header: string, types: string[]) {
const message = groupTitleMessages[header]
if (!message) {
return formatCategoryHeader(formatMessage, header)
}
const showType = projectTypes.value.length > 1
return formatMessage(message, {
showType: showType ? 'yes' : 'other',
types: showType
? capitalizeString(projectTypeListFormatter.value.format(types.map(formatProjectTypeName)))
: '',
})
}
function formatGroupDescription(header: string, types: string[]) {
const message = groupDescriptionMessages[header]
if (!message) {
return undefined
}
// listing out every type gets unwieldy, fallback to the generic term when there's more than one
return formatMessage(message, {
type: formatProjectTypeName(types.length > 1 ? 'project' : types[0]),
})
}
const categorySections = computed(() => {
const sections: {
categoryList: string
header: string
types: string[]
categories: Category[]
}[] = []
for (const [categoryList, types] of projectTypesByCategoryList.value) {
const byHeader = new Map<string, Category[]>()
for (const category of allCategories.value) {
if (category.project_type !== categoryList) continue
byHeader.set(category.header, [...(byHeader.get(category.header) ?? []), category])
}
for (const [header, categories] of byHeader) {
sections.push({
categoryList,
header,
types,
categories:
header === 'minecraft_server_features' ? withPokemonFirst(categories) : categories,
})
}
}
return sections
})
const categoryGroups = computed<CategoryGroup[]>(() =>
categorySections.value.map((section) => ({
id: `${section.categoryList}-${section.header}`,
title: formatGroupTitle(section.header, section.types),
description: formatGroupDescription(section.header, section.types),
categories: section.categories,
})),
)
function withPokemonFirst(categories: Category[]) {
return categories.slice().sort((a, b) => {
if (a.name === 'pokemon') return -1
if (b.name === 'pokemon') return 1
return 0
})
}
const availableTags = computed(() => [
...new Set(categorySections.value.flatMap((section) => section.categories.map((x) => x.name))),
])
function hasSameTags(a: string[], b: string[]) {
return a.length === b.length && a.every((tag) => b.includes(tag))
}
const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => ({
selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(x: Category) =>
matchesProjectType(x) &&
(project.value.categories.includes(x.name) ||
project.value.additional_categories.includes(x.name)),
) as Category[],
featuredTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(x: Category) => matchesProjectType(x) && project.value.categories.includes(x.name),
) as Category[],
selectedTags: availableTags.value.filter(
(tag) =>
project.value.categories.includes(tag) || project.value.additional_categories.includes(tag),
),
featuredTags: availableTags.value.filter((tag) => project.value.categories.includes(tag)),
}),
async () => {
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = current.value.featuredTags.slice()
if (newFeaturedTags.length < 1 && current.value.selectedTags.length > newFeaturedTags.length) {
const nonFeaturedCategories = current.value.selectedTags.filter(
(x) => !newFeaturedTags.includes(x),
)
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x))
if (!canSave.value) {
throw new Error('At least one tag must be featured')
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name)
const additionalCategories = current.value.selectedTags
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name)
const featuredTags = current.value.featuredTags
const additionalCategories = current.value.selectedTags.filter(
(tag) => !featuredTags.includes(tag),
)
const data: Record<string, string[]> = {}
if (
categories.length !== project.value.categories.length ||
categories.some((value) => !project.value.categories.includes(value))
) {
data.categories = categories
if (!hasSameTags(featuredTags, project.value.categories)) {
data.categories = featuredTags
}
if (
additionalCategories.length !== project.value.additional_categories.length ||
additionalCategories.some((value) => !project.value.additional_categories.includes(value))
) {
if (!hasSameTags(additionalCategories, project.value.additional_categories)) {
data.additional_categories = additionalCategories
}
@@ -327,31 +302,15 @@ const { saved, current, saving, hasChanges, reset, save } = useSavable(
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {}
sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => {
if (matchesProjectType(x)) {
const header = x.header
if (!lists[header]) {
lists[header] = []
}
lists[header].push(x)
}
})
const featuresKey = 'minecraft_server_features'
if (lists[featuresKey]) {
lists[featuresKey].sort((a, b) => {
if (a.name === 'pokemon') return -1
if (b.name === 'pokemon') return 1
return 0
})
}
return lists
})
const isFeaturedLimitReached = computed(
() => current.value.featuredTags.length >= MAX_FEATURED_TAGS,
)
const canSave = computed(() => current.value.featuredTags.length > 0)
const tooManyTagsWarning = computed(() => {
const tagCount = current.value.selectedTags.length
if (projectV3?.value?.minecraft_server != null) {
if (isServerProject.value) {
if (tagCount > 18) {
return formatMessage(messages.tooManyTagsServerHardWarning, { count: tagCount })
} else if (tagCount > 12) {
@@ -364,122 +323,140 @@ const tooManyTagsWarning = computed(() => {
})
const multipleResolutionTagsWarning = computed(() => {
if (project.value.actualProjectType !== 'resourcepack') return null
if (!projectTypes.value.includes('resourcepack')) return null
const resolutionTags = current.value.selectedTags.filter((tag) =>
['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+'].includes(tag.name),
)
const resolutionTags = current.value.selectedTags.filter((tag) => RESOLUTION_TAGS.includes(tag))
if (resolutionTags.length < 2) return null
if (resolutionTags.length > 1) {
const tagsList = resolutionTags
.map((t) => t.name)
return formatMessage(messages.multipleResolutionTagsWarning, {
count: resolutionTags.length,
tags: resolutionTags
.join(', ')
.replace('8x-', '8x or lower')
.replace('512x+', '512x or higher')
return formatMessage(messages.multipleResolutionTagsWarning, {
count: resolutionTags.length,
tags: tagsList,
})
}
return null
.replace('512x+', '512x or higher'),
})
})
const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(
tags.value,
formatCategoryName,
locale.value,
).filter((x: Category) => x.project_type === project.value.actualProjectType)
const totalSelectedTags = current.value.selectedTags.length
if (
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
availableTags.value.length < 1 ||
availableTags.value.length < 1 ||
current.value.selectedTags.length !== availableTags.value.length
) {
return formatMessage(messages.allTagsSelectedWarning, {
count: categoriesForProjectType.length,
})
return null
}
return null
return formatMessage(messages.allTagsSelectedWarning, { count: availableTags.value.length })
})
const toggleCategory = (category: Category) => {
if (current.value.selectedTags.includes(category)) {
current.value.selectedTags = current.value.selectedTags.filter((x) => x !== category)
if (current.value.featuredTags.includes(category)) {
current.value.featuredTags = current.value.featuredTags.filter((x) => x !== category)
}
} else {
current.value.selectedTags = [...current.value.selectedTags, category]
function toggleTagRaw(selection: string[], tag: string) {
if (selection.includes(tag)) {
return selection.filter((x) => x !== tag)
}
return availableTags.value.filter((x) => x === tag || selection.includes(x))
}
const toggleTag = (tag: string) => {
current.value.selectedTags = toggleTagRaw(current.value.selectedTags, tag)
if (!current.value.selectedTags.includes(tag)) {
current.value.featuredTags = current.value.featuredTags.filter((x) => x !== tag)
}
}
const toggleFeaturedCategory = (category: Category) => {
if (current.value.featuredTags.includes(category)) {
current.value.featuredTags = current.value.featuredTags.filter((x) => x !== category)
} else {
current.value.featuredTags = [...current.value.featuredTags, category]
}
const toggleFeatured = (tag: string) => {
current.value.featuredTags = toggleTagRaw(current.value.featuredTags, tag)
}
</script>
<template>
<div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<h2 class="mb-4 mt-0 text-2xl font-semibold">
{{ formatMessage(messages.title) }}
</h2>
<p v-if="!canSelectTags" class="known-errors">
{{ formatMessage(messages.uploadVersionFirst) }}
</p>
<div v-else class="flex flex-col gap-4">
<Admonition v-if="allTagsSelectedWarning" type="critical" :body="allTagsSelectedWarning" />
<Admonition v-else-if="tooManyTagsWarning" type="warning" :body="tooManyTagsWarning" />
<Admonition
v-if="multipleResolutionTagsWarning"
type="warning"
:body="multipleResolutionTagsWarning"
/>
<style lang="scss" scoped>
.label__title {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
margin-top: var(--spacing-card-bg);
<div
v-for="group in categoryGroups"
:key="group.id"
class="rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
>
<h3 class="mb-1 mt-0 text-base font-semibold text-contrast">
{{ group.title }}
</h3>
<p v-if="group.description" class="mb-3 mt-0 text-sm">
{{ group.description }}
</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<Checkbox
v-for="category in group.categories"
:key="`${group.id}-${category.name}`"
:model-value="current.selectedTags.includes(category.name)"
:description="formatCategoryName(category.name)"
@update:model-value="toggleTag(category.name)"
>
<span aria-hidden="true">
<FormattedTag :tag="category.name" enforce-type="category" />
</span>
</Checkbox>
</div>
</div>
svg {
vertical-align: top;
}
}
.category-list {
column-count: 4;
column-gap: var(--spacing-card-lg);
margin-bottom: var(--spacing-card-md);
:deep(.category-selector) {
margin-bottom: 0.75rem;
.category-selector__label {
display: flex;
align-items: center;
text-align: left;
.icon {
height: 1rem;
width: 1rem;
margin-right: 0.25rem;
display: flex;
align-items: center;
svg {
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
}
@media only screen and (max-width: 1250px) {
column-count: 3;
}
@media only screen and (max-width: 1024px) {
column-count: 4;
}
@media only screen and (max-width: 960px) {
column-count: 3;
}
@media only screen and (max-width: 750px) {
column-count: 2;
}
@media only screen and (max-width: 530px) {
column-count: 1;
}
}
</style>
<div class="rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="mb-1 flex items-center gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.featuredTags) }}
</h3>
<TagItem
v-tooltip="
current.featuredTags.length === 0
? formatMessage(messages.featuredTagsRequired)
: undefined
"
:style="canSave ? undefined : { '--_color': 'var(--color-red)' }"
>
{{ current.featuredTags.length }}/{{ MAX_FEATURED_TAGS }}
</TagItem>
</div>
<p class="mb-3 mt-0 text-sm">
{{ formatMessage(messages.featuredTagsDescription) }}
</p>
<p v-if="current.selectedTags.length < 1" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.selectAtLeastOneCategory) }}
</p>
<div v-else class="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<Checkbox
v-for="name in current.selectedTags"
:key="`featured-${name}`"
:model-value="current.featuredTags.includes(name)"
:description="formatCategoryName(name)"
:disabled="isFeaturedLimitReached && !current.featuredTags.includes(name)"
@update:model-value="toggleFeatured(name)"
>
<span aria-hidden="true">
<FormattedTag :tag="name" enforce-type="category" />
</span>
</Checkbox>
</div>
</div>
</div>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
:can-save="canSave"
:save-disabled-reason="messages.featuredTagsRequired"
@reset="reset"
@save="save"
/>
</div>
</template>
@@ -425,6 +425,7 @@ import {
ProjectCardList,
RadioButtons,
SidebarCard,
sortProjectTypes,
StyledInput,
TeleportOverflowMenu,
useCompactNumber,
@@ -769,7 +770,7 @@ const projectTypes = computed(() => {
projects.value?.map((project) => project?.project_type).filter((x) => x !== undefined) || [],
)
projectSet.delete('project')
return Array.from(projectSet)
return sortProjectTypes(projectSet)
})
function getProjectTypeSentenceMessage(type) {
@@ -236,6 +236,7 @@ import {
ProjectCard,
ProjectCardList,
SidebarCard,
sortProjectTypes,
useCompactNumber,
useVIntl,
} from '@modrinth/ui'
@@ -376,15 +377,14 @@ const isInvited = computed(() => {
})
const projectTypes = computed(() => {
const obj: Record<string, boolean> = {}
const types = new Set<string>()
for (const project of projects.value ?? []) {
obj[project.project_types[0] ?? 'project'] = true
const type = project.project_types[0] ?? 'project'
if (type !== 'project') types.add(type)
}
delete obj.project
return Object.keys(obj)
return sortProjectTypes(types)
})
function isProjectServer(project: ProjectV3): boolean {
return project.minecraft_server != null
@@ -494,15 +494,12 @@ const navLinks = computed(() => [
label: formatMessage(commonMessages.allProjectType),
href: `/organization/${organization.value?.slug}`,
},
...projectTypes.value
.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x as ProjectType, true)),
href: `/organization/${organization.value?.slug}/${x}s`,
}
})
.slice()
.sort((a, b) => a.label.localeCompare(b.label)),
...projectTypes.value.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x as ProjectType, true)),
href: `/organization/${organization.value?.slug}/${x}s`,
}
}),
])
async function copyId() {