update archiving, move general project settings to v3 (start redesign there), use project id lookups for stable cache keys, advanced in server search

This commit is contained in:
Prospector
2026-08-06 21:27:44 -07:00
parent f7c5552354
commit d94d6c796e
58 changed files with 912 additions and 510 deletions
+14 -1
View File
@@ -108,7 +108,15 @@ import { getSeverityIcon } from '../../utils'
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' | 'neutral'
type?:
| 'info'
| 'warning'
| 'critical'
| 'success'
| 'moderation'
| 'circle-warning'
| 'neutral'
| 'grey'
header?: string
body?: string
inlineActions?: boolean
@@ -174,6 +182,7 @@ const typeClasses = {
success: 'border-brand-green bg-bg-green',
moderation: 'border-brand-orange bg-bg-orange',
neutral: 'border-surface-4 bg-surface-3',
grey: 'border-gray-500 bg-gray-950',
}
const iconClasses = {
@@ -184,6 +193,7 @@ const iconClasses = {
success: 'text-brand-green',
moderation: 'text-brand-orange',
neutral: 'text-secondary',
grey: 'text-secondary',
}
const buttonColors = {
@@ -194,6 +204,7 @@ const buttonColors = {
success: 'green',
moderation: 'orange',
neutral: 'standard',
grey: 'standard',
} as const
const progressTrackClasses = {
@@ -204,6 +215,7 @@ const progressTrackClasses = {
success: 'bg-brand-green/20',
moderation: 'bg-brand-orange/20',
neutral: 'bg-surface-4',
grey: 'bg-surface-4',
}
const progressFillClasses = {
@@ -216,6 +228,7 @@ const progressFillClasses = {
green: 'bg-brand-green',
red: 'bg-brand-red',
neutral: 'bg-surface-5',
grey: 'bg-surface-5',
}
</script>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
title: string
icon?: Component
description?: string
}>()
</script>
<template>
<div
class="grid w-full grid-cols-[1fr_auto] items-center gap-6 p-4 rounded-2xl border border-solid border-surface-4 bg-surface-3"
>
<div>
<h3 class="mb-1 mt-0 flex items-center gap-2 text-base font-semibold text-contrast">
<component :is="icon" v-if="icon" class="size-5 shrink-0 text-primary" aria-hidden="true" />
{{ title }}
</h3>
<div
v-if="description || $slots.default"
class="flex flex-col gap-2 text-sm leading-normal [&>p]:m-0"
>
<p v-if="description">{{ description }}</p>
<slot />
</div>
</div>
<div>
<slot name="actions" />
</div>
</div>
</template>
@@ -5,7 +5,8 @@
v-bind="$attrs"
class="smart-clickable__contents"
:class="{
'pointer-events-none': !!$slots.clickable,
'smart-clickable__contents--disabled': disabled,
'pointer-events-none': !!$slots.clickable && !disabled,
}"
>
<slot />
@@ -14,6 +15,10 @@
</template>
<script setup lang="ts">
defineProps<{
disabled?: boolean
}>()
defineOptions({
inheritAttrs: false,
})
@@ -77,7 +82,7 @@ defineOptions({
}
// When clickable is being clicked, give contents an effect
:first-child:active + .smart-clickable__contents {
:first-child:active + .smart-clickable__contents:not(.smart-clickable__contents--disabled) {
scale: 0.98;
}
}
@@ -20,6 +20,7 @@ function toggle() {
<template>
<SmartClickable
class="flex w-full flex-col overflow-clip rounded-2xl border border-solid border-surface-4 bg-surface-3"
:disabled="disabled"
>
<template #clickable>
<button
+1
View File
@@ -109,6 +109,7 @@ export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsFormGroup } from './SettingsFormGroup.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SettingsOptionCard } from './SettingsOptionCard.vue'
export { default as SettingsToggleCard } from './SettingsToggleCard.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
@@ -1,10 +1,12 @@
<template>
<NewModal ref="modal" :noblur="noblur" :danger="danger" :on-hide="onHide" max-width="550px">
<template #title>
<slot name="title">
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
</slot>
</template>
<NewModal
ref="modal"
:noblur="noblur"
:danger="danger"
:on-hide="onHide"
max-width="800px"
:header="title"
>
<div class="flex flex-col gap-4">
<template v-if="description">
<div
@@ -20,7 +22,7 @@
<label v-if="hasToType" for="confirmation">
<span>
To confirm you want to proceed, type
<span class="italic font-bold">{{ confirmationText }}</span> below:
<span class="font-semibold text-contrast">{{ confirmationText }}</span> below:
</span>
</label>
<StyledInput
@@ -66,7 +66,7 @@ const messages = defineMessages({
},
body3: {
id: 'search.photosensitivity-warning-modal.body.3',
defaultMessage: 'Using any content on Modrinth is at your own risk, please stay safe! 💚',
defaultMessage: 'Using any content on Modrinth is at your own risk. Please stay safe! 💚',
},
dontShowAgain: {
id: 'search.photosensitivity-warning-modal.dont-show-again',
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ArchiveIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const props = defineProps<{
title: string
reason?: string | null
}>()
const { formatMessage } = useVIntl()
const trimmedReason = computed(() => props.reason?.trim() || null)
const messages = defineMessages({
header: {
id: 'project.status.archived.header',
defaultMessage: '{title} has been archived',
},
body: {
id: 'project.status.archived.body',
defaultMessage:
'{title} will not receive any further updates unless the author decides to unarchive the project.',
},
})
</script>
<template>
<Admonition type="grey" :header="formatMessage(messages.header, { title })">
<template #icon="{ iconClass }">
<ArchiveIcon :class="iconClass" />
</template>
<span>{{ trimmedReason ?? formatMessage(messages.body, { title }) }}</span>
</Admonition>
</template>
@@ -9,7 +9,7 @@
</template>
<template v-if="showStatusBadge" #badges>
<ProjectStatusBadge :status="project.status" />
<ProjectStatusBadge :status="projectV3.status" />
</template>
<template #metadata>
@@ -2,6 +2,7 @@ export * from './server'
export * from './settings'
// Other
export { default as ArchivedProjectBanner } from './ArchivedProjectBanner.vue'
export { default as ProjectCard } from './card/ProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectCardList } from './ProjectCardList.vue'
@@ -36,7 +36,10 @@ function isPhotosensitivityExclusionSelected(filters: { type: string; option: st
}
watch(
() => isPhotosensitivityExclusionSelected(ctx.currentFilters.value),
() =>
isPhotosensitivityExclusionSelected(
ctx.isServerType.value ? ctx.serverCurrentFilters.value : ctx.currentFilters.value,
),
(isSelected, wasSelected) => {
if (isSelected && !wasSelected && !ctx.dismissedPhotosensitivityFilterWarning?.value) {
nextTick(() => photosensitivityWarningModal.value?.show())
@@ -204,6 +207,8 @@ function getFilterOpenByDefault(filterId: string): boolean {
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:open-by-default="getFilterOpenByDefault(filterType.id)"
@on-open="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filterType.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-base font-semibold'">
+7 -1
View File
@@ -3776,6 +3776,12 @@
"project.stats.followers-label": {
"defaultMessage": "{count, plural, one {follower} other {followers}}"
},
"project.status.archived.body": {
"defaultMessage": "{title} will not receive any further updates unless the author decides to unarchive the project."
},
"project.status.archived.header": {
"defaultMessage": "{title} has been archived"
},
"project.versions.channel.alpha.symbol": {
"defaultMessage": "A"
},
@@ -3960,7 +3966,7 @@
"defaultMessage": "Content labels for photosensitivity triggers are self-assigned by the creators who upload their content to Modrinth. These projects have not gone through any safety testing."
},
"search.photosensitivity-warning-modal.body.3": {
"defaultMessage": "Using any content on Modrinth is at your own risk, please stay safe! 💚"
"defaultMessage": "Using any content on Modrinth is at your own risk. Please stay safe! 💚"
},
"search.photosensitivity-warning-modal.dont-show-again": {
"defaultMessage": "Don't show this again"
+88 -73
View File
@@ -146,7 +146,7 @@ const DISCLOSURE_TYPE_FILTERS = [
'archived',
] as const
type DisclosureTypeFilter = (typeof DISCLOSURE_TYPE_FILTERS)[number]
export type DisclosureTypeFilter = (typeof DISCLOSURE_TYPE_FILTERS)[number]
const DISCLOSURE_TYPE_SUPPORTED_PROJECT_TYPES: Record<
DisclosureTypeFilter,
@@ -177,6 +177,92 @@ function isProjectTypeExclusionOption(optionId: string): optionId is ProjectType
return (ALL_PROJECT_TYPES as string[]).includes(optionId)
}
type FormatMessage = (
descriptor: { id: string; defaultMessage: string },
values?: Record<string, unknown>,
) => string
export function formatDisclosureTypeLabel(
formatMessage: FormatMessage,
disclosureType: DisclosureTypeFilter,
): string {
switch (disclosureType) {
case 'ai_content':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.ai_content',
defaultMessage: 'AI-generated content',
}),
)
case 'advertisements':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.advertisements',
defaultMessage: 'Advertisements',
}),
)
case 'epilepsy_triggers':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.epilepsy_triggers',
defaultMessage: 'Photosensitivity triggers',
}),
)
case 'system_interactions':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.system_interactions',
defaultMessage: 'External system interactions',
}),
)
case 'telemetry':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.telemetry',
defaultMessage: 'Telemetry',
}),
)
case 'derivative_work':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.derivative_work',
defaultMessage: 'Derivative content',
}),
)
case 'paid_features':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.paid_features',
defaultMessage: 'Paid features',
}),
)
case 'archived':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.archived',
defaultMessage: 'Archived',
}),
)
}
}
export function createDisclosureFilterOptions(
formatMessage: FormatMessage,
projectTypes: readonly ProjectType[],
): FilterOption[] {
return DISCLOSURE_TYPE_FILTERS.filter((disclosureType) =>
DISCLOSURE_TYPE_SUPPORTED_PROJECT_TYPES[disclosureType].some((projectType) =>
projectTypes.includes(projectType),
),
).map((disclosureType) => ({
id: disclosureType,
formatted_name: formatDisclosureTypeLabel(formatMessage, disclosureType),
icon: DISCLOSURE_TYPE_ICONS[disclosureType],
method: 'or' as const,
value: `disclosure_types:${disclosureType}`,
}))
}
export function useSearch(
projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>,
@@ -208,67 +294,6 @@ export function useSearch(
return formatCategory(formatMessage, categoryName)
}
const formatDisclosureTypeLabel = (disclosureType: DisclosureTypeFilter): string => {
switch (disclosureType) {
case 'ai_content':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.ai_content',
defaultMessage: 'AI-generated content',
}),
)
case 'advertisements':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.advertisements',
defaultMessage: 'Advertisements',
}),
)
case 'epilepsy_triggers':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.epilepsy_triggers',
defaultMessage: 'Photosensitivity triggers',
}),
)
case 'system_interactions':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.system_interactions',
defaultMessage: 'External system interactions',
}),
)
case 'telemetry':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.telemetry',
defaultMessage: 'Telemetry',
}),
)
case 'derivative_work':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.derivative_work',
defaultMessage: 'Derivative content',
}),
)
case 'paid_features':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.paid_features',
defaultMessage: 'Paid features',
}),
)
case 'archived':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.disclosure.archived',
defaultMessage: 'Archived',
}),
)
}
}
const filters = computed(() => {
const categoryFilters: Record<string, FilterType> = {}
for (const category of sortedCategories(tags.value, formatCategoryName, locale.value)) {
@@ -579,17 +604,7 @@ export function useSearch(
searchable: false,
ordering: -1000,
options: [
...DISCLOSURE_TYPE_FILTERS.filter((disclosureType) =>
DISCLOSURE_TYPE_SUPPORTED_PROJECT_TYPES[disclosureType].some((projectType) =>
projectTypes.value.includes(projectType),
),
).map((disclosureType) => ({
id: disclosureType,
formatted_name: formatDisclosureTypeLabel(disclosureType),
icon: DISCLOSURE_TYPE_ICONS[disclosureType],
method: 'or' as const,
value: `disclosure_types:${disclosureType}`,
})),
...createDisclosureFilterOptions(formatMessage, projectTypes.value),
...excludeableProjectTypes.map((target) => ({
id: target,
formatted_name: formatMessage(getProjectTypeCategoryMessage(target)),
+36 -3
View File
@@ -6,7 +6,7 @@ import { useRoute } from 'vue-router'
import { defineMessage, LOCALES, useVIntl } from '../composables/i18n'
import type { FilterType, FilterValue, SortType, Tags } from './search'
import { formatSearchFilterValue } from './search'
import { createDisclosureFilterOptions, formatSearchFilterValue } from './search'
import { formatCategory, formatCategoryHeader } from './tag-messages'
export const SERVER_REGIONS = {
@@ -339,6 +339,22 @@ export function useServerSearch(opts: {
},
],
},
{
id: 'advanced',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.advanced',
defaultMessage: 'Advanced exclusions',
}),
),
supported_project_types: ['server'],
display: 'all',
query_param: 'a',
supports: ['exclude'],
searchable: false,
ordering: -1000,
options: createDisclosureFilterOptions(formatMessage, ['server']),
},
]
})
@@ -346,11 +362,28 @@ export function useServerSearch(opts: {
const parts = ['project_types = minecraft_java_server']
for (const filterType of serverFilterTypes.value) {
const field = getFilterField(filterType.id)
if (!field) continue
const matched = serverCurrentFilters.value.filter((f) => f.type === filterType.id)
if (matched.length === 0) continue
if (filterType.id === 'advanced') {
const disclosureValues = matched
.map((filterValue) => {
const option = filterType.options.find((option) => option.id === filterValue.option)
if (!option || !('value' in option)) return null
const [, val] = option.value.split(':')
return val
})
.filter((val): val is string => !!val)
if (disclosureValues.length > 0) {
const quoted = disclosureValues.map(formatSearchFilterValue).join(', ')
parts.push(`disclosure_types NOT IN [${quoted}]`)
}
continue
}
const field = getFilterField(filterType.id)
if (!field) continue
if (filterType.id === 'server_status') {
const selected = matched[0]?.option
if (selected === 'online') {