mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +00:00
project disclosures frontend (#6955)
* begin project disclosures * new project settings header * begin disclosure settings, edit content rules * togglecards * update phrasing of the rules, prepr * more disclosure settings structuring * add functionality toggle * implement functionality with staging api * improve project settings head titles * feat(labrinth): project disclosures model * feat(labrinth): project disclosures database model * feat(labrinth): project disclosures get endpoint * feat(labrinth): censor user ids if set by moderator * feat(labrinth): wrap disclosures in struct * feat(labrinth): edit project disclosures endpoint * style(labrinth): cargo fmt * style(labrinth): fix typo * feat(labrinth): add fields for ai content disclosure * fix(labrinth): field typo * chore(labrinth): update query cache * feat(labrinth): index disclosures in search * refactor(labrinth): use enum instead of bools * feat(labrinth): trigger incremental index on disclosure change * feat(labrinth): change archived status to disclosure * feat(labrinth): use disclosure for archival status * fix(labrinth): error type for deserialization * fix(labrinth): migration timestamp * fix(labrinth): add disclosures to elasticsearch schema * disclosures in search * update photosensitivity warning copy * update archiving, move general project settings to v3 (start redesign there), use project id lookups for stable cache keys, advanced in server search * blue archive banner * display AI use types * improve labels * add modrinth to link * add placeholder missing disclosure report type * add AI metadata checking to block image uploads with notice * update copy * update section 4 of rules * Update rule 6 layout * fix(labrinth): don't remove archival disclosure when changing status via v3 api * feat(labrinth): derive str for ai usages and telemtry consent * feat(labrinth): include ai usages and telemetry consent in disclosure types * Update moderation checklist (#7056) * fix: missing delphi severity (#7054) * Utils nav, new disclosures stage, ai button in rules * add prefix to collect * Finish up new disclosures stage * update r4 messages * new r4 msg, update showcase clarity message * fix: version upload failing to detect mrpack loader (#7063) * fix: mrpack exporting with zip64 (#7064) * fix: action bar max width (#7048) * fix: action bar max width * fix: width * changelog * Rule placeholders with subsections & anchors, update messages. * update new messages to account for new rule and placeholder layout * Add nag for content disclosures --------- Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech> Co-authored-by: chyzman <chyzalt@gmail.com> Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> * prepr * refactor(labrinth): move disclosure parsing to document creation * fix(labrinth): prevent removing moderator added archival disclosures * fix(labrinth): dedupe ai usages * fix(labrinth): auth logic on archived disclosure removal * feat(labrinth): add interactions field * style(labrinth): cargo fmt * feat(labrinth): granular disclosure locking * add 5.8, move 5.9, update messages and placeholders accordingly. * move disclosures stage earlier * include consent model info in telemetry disclosure msgs * prepr + update date * typo * qa pass * search suboptions * add empty state * checkboxes in column * persistent advanced filters * blog draft * prepr * support soft deletion * reload instead of optimistically updating * update blog * TOS update + minor verbiage adjustment * 45 day grace --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com> Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com> Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech> Co-authored-by: chyzman <chyzalt@gmail.com> Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
This commit is contained in:
co-authored by
ThatGravyBoat
chyzman
Truman Gao
sychic
coolbot
parent
06b7c44dcc
commit
755825b09a
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
|
||||
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
|
||||
centerContent ? 'items-center' : 'items-start',
|
||||
progress != null ? 'overflow-hidden pb-5' : '',
|
||||
typeClasses[type],
|
||||
]"
|
||||
@@ -13,7 +14,7 @@
|
||||
class="col-start-2 min-w-0"
|
||||
:class="
|
||||
inlineActions && !showActionsUnderneath && $slots.actions
|
||||
? 'flex flex-wrap items-start gap-x-4 gap-y-3'
|
||||
? ['flex flex-wrap gap-x-4 gap-y-3', centerContent ? 'items-center' : 'items-start']
|
||||
: 'flex flex-1 flex-col gap-2'
|
||||
"
|
||||
>
|
||||
@@ -39,7 +40,7 @@
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/85 leading-tight">
|
||||
<div v-if="!!$slots.default || body" class="font-normal text-contrast/85 leading-tight">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +120,7 @@ const props = withDefaults(
|
||||
waiting?: boolean
|
||||
/** Accepts a Date, an ISO string, or a millisecond Unix timestamp. */
|
||||
timestamp?: Date | string | number
|
||||
centerContent?: boolean
|
||||
}>(),
|
||||
{
|
||||
type: 'info',
|
||||
@@ -131,6 +133,7 @@ const props = withDefaults(
|
||||
progressColor: undefined,
|
||||
waiting: false,
|
||||
timestamp: undefined,
|
||||
centerContent: false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat'
|
||||
import { computed, markRaw, useSlots, type VNode } from 'vue'
|
||||
import { computed, defineComponent, markRaw, type PropType, useSlots, type VNode } from 'vue'
|
||||
|
||||
import type { MessageDescriptor } from '../../composables/i18n'
|
||||
import { injectI18nDebug } from '../../composables/i18n-debug'
|
||||
import { injectI18n } from '../../providers/i18n'
|
||||
|
||||
const VNodeRenderer = defineComponent({
|
||||
props: {
|
||||
node: { type: Object as PropType<VNode>, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
return () => props.node
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
messageId: MessageDescriptor
|
||||
values?: Record<string, PrimitiveType>
|
||||
@@ -49,11 +58,13 @@ const formattedParts = computed(() => {
|
||||
slotHandlers[normalizedName] = (chunks) => {
|
||||
const slot = slots[slotName]
|
||||
if (slot) {
|
||||
return markRaw(
|
||||
slot({
|
||||
children: chunks,
|
||||
}),
|
||||
) as VNode[]
|
||||
const nodes = slot({
|
||||
children: chunks,
|
||||
})
|
||||
if (Array.isArray(nodes) && nodes.length === 1) {
|
||||
return markRaw(nodes[0]) as VNode
|
||||
}
|
||||
return markRaw(nodes) as VNode[]
|
||||
}
|
||||
return markRaw(chunks) as VNode[]
|
||||
}
|
||||
@@ -71,33 +82,35 @@ const formattedParts = computed(() => {
|
||||
...slotHandlers,
|
||||
})
|
||||
|
||||
// ensure result array items are marked as raw if they're VNodes
|
||||
// prevents VNodes from entering the reactive system and SSR payload
|
||||
if (Array.isArray(result)) {
|
||||
return result.map((part) =>
|
||||
typeof part === 'object' && part !== null ? markRaw(part) : part,
|
||||
)
|
||||
}
|
||||
return [typeof result === 'object' && result !== null ? markRaw(result) : result]
|
||||
return toFormattedParts(result)
|
||||
} catch {
|
||||
return [msg]
|
||||
}
|
||||
})
|
||||
|
||||
function toFormattedParts(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(toFormattedParts)
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return [markRaw(value)]
|
||||
}
|
||||
return [value]
|
||||
}
|
||||
|
||||
function isVNodePart(part: unknown): part is VNode {
|
||||
return typeof part === 'object' && part !== null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="debugEnabled && !debugKeyReveal"
|
||||
:data-i18n-key="messageId.id"
|
||||
style="display: contents"
|
||||
:data-i18n-key="debugEnabled && !debugKeyReveal ? messageId.id : undefined"
|
||||
>
|
||||
<template v-for="(part, index) in formattedParts" :key="index">
|
||||
<component :is="() => part" v-if="typeof part === 'object'" />
|
||||
<VNodeRenderer v-if="isVNodePart(part)" :node="part" />
|
||||
<template v-else>{{ part }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<template v-for="(part, index) in formattedParts" v-else :key="index">
|
||||
<component :is="() => part" v-if="typeof part === 'object'" />
|
||||
<template v-else>{{ part }}</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessage, useVIntl } from '../../composables/i18n'
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
titleFor?: string
|
||||
description?: string
|
||||
optional?: boolean
|
||||
}>(),
|
||||
{
|
||||
optional: false,
|
||||
},
|
||||
)
|
||||
|
||||
const optionalMessage = defineMessage({
|
||||
id: 'settings.form.group.optional',
|
||||
defaultMessage: '(optional)',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="title || description || $slots.title || $slots.description"
|
||||
:class="description || $slots.description ? 'flex flex-col gap-1' : undefined"
|
||||
>
|
||||
<label
|
||||
v-if="title && titleFor"
|
||||
:for="titleFor"
|
||||
class="m-0 leading-normal text-contrast font-semibold"
|
||||
>
|
||||
{{ title }}
|
||||
<span v-if="optional" class="text-secondary font-normal">
|
||||
{{ formatMessage(optionalMessage) }}
|
||||
</span>
|
||||
</label>
|
||||
<p v-else-if="title" class="m-0 leading-normal text-contrast font-semibold">
|
||||
{{ title }}
|
||||
<span v-if="optional" class="text-secondary font-normal">
|
||||
{{ formatMessage(optionalMessage) }}
|
||||
</span>
|
||||
</p>
|
||||
<slot name="title" />
|
||||
<p v-if="description" class="m-0 leading-normal text-primary">
|
||||
{{ description }}
|
||||
</p>
|
||||
<slot name="description" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlertIcon } from '@modrinth/assets'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1.5 text-orange">
|
||||
<TriangleAlertIcon aria-hidden="true" class="size-4 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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-lg 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 leading-normal [&>p]:m-0"
|
||||
>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import ToggleCard from './ToggleCard.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
icon?: Component
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
toggleDisabled?: boolean
|
||||
}>()
|
||||
|
||||
const enabled = defineModel<boolean>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleCard v-model="enabled" :disabled="disabled" :toggle-disabled="toggleDisabled">
|
||||
<h3 class="mb-1 mt-0 flex items-center gap-2 text-lg 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 leading-normal [&>p]:m-0">
|
||||
<p v-if="description">{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
<template v-if="$slots.expanded" #expanded>
|
||||
<div class="flex flex-col gap-4">
|
||||
<slot name="expanded" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</ToggleCard>
|
||||
</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,
|
||||
})
|
||||
@@ -49,8 +54,7 @@ defineOptions({
|
||||
// When clickable is being hovered or focus-visible, give contents an effect
|
||||
:first-child:hover + .smart-clickable__contents,
|
||||
:first-child:focus-visible + .smart-clickable__contents,
|
||||
.smart-clickable__contents:hover,
|
||||
.smart-clickable__contents:focus-within {
|
||||
.smart-clickable__contents:hover {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:underline-on-hover) {
|
||||
text-decoration: underline;
|
||||
@@ -78,8 +82,8 @@ defineOptions({
|
||||
}
|
||||
|
||||
// When clickable is being clicked, give contents an effect
|
||||
:first-child:active + .smart-clickable__contents {
|
||||
scale: 0.97;
|
||||
:first-child:active + .smart-clickable__contents:not(.smart-clickable__contents--disabled) {
|
||||
scale: 0.98;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { SmartClickable, Toggle } from '@modrinth/ui'
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
toggleDisabled?: boolean
|
||||
}>()
|
||||
|
||||
const value = defineModel<boolean>({ required: true })
|
||||
|
||||
const baseId = useId()
|
||||
const toggleId = computed(() => `toggle-card-toggle-${baseId}`)
|
||||
const isToggleLocked = computed(() => !!props.disabled || (!!props.toggleDisabled && value.value))
|
||||
|
||||
function toggle() {
|
||||
if (isToggleLocked.value) return
|
||||
value.value = !value.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SmartClickable
|
||||
class="flex w-full flex-col overflow-clip rounded-2xl border border-solid border-surface-4 bg-surface-3"
|
||||
:disabled="isToggleLocked"
|
||||
>
|
||||
<template #clickable>
|
||||
<button
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
:disabled="isToggleLocked"
|
||||
class="flex h-full w-full"
|
||||
:class="isToggleLocked ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-6 p-4">
|
||||
<div>
|
||||
<slot :toggle-id="toggleId" />
|
||||
</div>
|
||||
<div>
|
||||
<slot name="toggle" :disabled="isToggleLocked">
|
||||
<Toggle
|
||||
:id="toggleId"
|
||||
v-model="value"
|
||||
:disabled="isToggleLocked"
|
||||
class="smart-clickable:allow-pointer-events"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="toggle-card-content">
|
||||
<div v-if="value && $slots.expanded" class="smart-clickable:allow-pointer-events">
|
||||
<div>
|
||||
<div class="border-0 border-t border-solid border-surface-4 bg-surface-2 p-4">
|
||||
<slot name="expanded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="smart-clickable:allow-pointer-events border-0 border-t bg-surface-3 border-solid border-surface-4 px-4 py-3"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</SmartClickable>
|
||||
</template>
|
||||
<style scoped>
|
||||
.toggle-card-content-enter-active,
|
||||
.toggle-card-content-leave-active {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.25s var(--ease-out-expo);
|
||||
|
||||
& > div {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-card-content-enter-from,
|
||||
.toggle-card-content-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
</style>
|
||||
@@ -48,8 +48,10 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const shown = computed(() =>
|
||||
Object.keys(props.modified).some((key) => !isEqual(props.original[key], props.modified[key])),
|
||||
const shown = computed(
|
||||
() =>
|
||||
props.saving ||
|
||||
Object.keys(props.modified).some((key) => !isEqual(props.original[key], props.modified[key])),
|
||||
)
|
||||
|
||||
function localizeIfPossible(message: MessageDescriptor | string) {
|
||||
|
||||
@@ -45,6 +45,8 @@ const typeClasses: Record<ButtonType, string> = {
|
||||
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
|
||||
colored:
|
||||
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
|
||||
'colored-text':
|
||||
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
|
||||
outlined:
|
||||
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
|
||||
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
|
||||
@@ -100,7 +102,12 @@ const classes = computed(() => [
|
||||
])
|
||||
const style = computed((): CSSProperties | undefined => {
|
||||
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
|
||||
if (props.type !== 'colored' && props.type !== 'outlined' && props.type !== 'quiet')
|
||||
if (
|
||||
props.type !== 'colored' &&
|
||||
props.type !== 'colored-text' &&
|
||||
props.type !== 'outlined' &&
|
||||
props.type !== 'quiet'
|
||||
)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
@@ -125,7 +132,8 @@ defineExpose({ element })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.button-frame--base {
|
||||
.button-frame--base,
|
||||
.button-frame--colored-text {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--surface-5),
|
||||
0 1px 1px rgba(0, 0, 0, 0.12);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { AnchoredTeleportPlacement } from '../../../utils/use-anchored-teleport'
|
||||
|
||||
export type ButtonType = 'base' | 'colored' | 'outlined' | 'quiet'
|
||||
export type ButtonType = 'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet'
|
||||
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
@@ -35,6 +35,10 @@ export type ButtonVisualProps = {
|
||||
type: 'colored'
|
||||
color?: ButtonColor
|
||||
}
|
||||
| {
|
||||
type: 'colored-text'
|
||||
color?: ButtonColor
|
||||
}
|
||||
| {
|
||||
type: 'quiet'
|
||||
color?: ButtonColor
|
||||
|
||||
@@ -107,7 +107,11 @@ export { default as RadioButtons } from './RadioButtons.vue'
|
||||
export { default as ReadyTransition } from './ReadyTransition.vue'
|
||||
export { default as ScrollablePanel } from './ScrollablePanel.vue'
|
||||
export { default as ServerNotice } from './ServerNotice.vue'
|
||||
export { default as SettingsFormGroup } from './SettingsFormGroup.vue'
|
||||
export { default as SettingsInlineWarning } from './SettingsInlineWarning.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'
|
||||
export { default as SmartClickable } from './SmartClickable.vue'
|
||||
@@ -132,4 +136,5 @@ export type {
|
||||
export { default as TimeFramePicker } from './TimeFramePicker.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as Toggle } from './Toggle.vue'
|
||||
export { default as ToggleCard } from './ToggleCard.vue'
|
||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { type Component, computed } from 'vue'
|
||||
|
||||
import PrideCollectionWidget from './PrideCollectionWidget.vue'
|
||||
import Rule6Widget from './Rule6Widget.vue'
|
||||
import SparkLiveWidget from './SparkLiveWidget.vue'
|
||||
import SparkLiveWidgetEmbed from './SparkLiveWidgetEmbed.vue'
|
||||
|
||||
@@ -9,6 +10,7 @@ const ARTICLE_WIDGETS: Record<string, Component> = {
|
||||
'spark-live-widget': SparkLiveWidget,
|
||||
'spark-live-widget-embed': SparkLiveWidgetEmbed,
|
||||
'pride-collection-widget': PrideCollectionWidget,
|
||||
'rule-6': Rule6Widget,
|
||||
}
|
||||
|
||||
type ArticleBodyPart = { type: 'html'; content: string } | { type: 'widget'; id: string }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div
|
||||
id="rule-6"
|
||||
class="rounded-2xl border border-solid border-surface-4 bg-surface-3 markdown-body p-4"
|
||||
>
|
||||
<nuxt-link to="/legal/rules#generative-ai" target="_blank">
|
||||
<h2 class="!mt-0">6. Usage of Generative "AI"</h2>
|
||||
</nuxt-link>
|
||||
|
||||
<p>
|
||||
Projects must be forthright and honest about the usage of generative AI in their production,
|
||||
publication, and within the project itself. Projects cannot be entirely or primarily comprised
|
||||
of content created or derived from generative AI output.
|
||||
</p>
|
||||
|
||||
<h3 id="disclosure-of-ai-generated-content">6.1. Disclosure of AI generated content</h3>
|
||||
|
||||
<p>You must apply the appropriate “Contains AI-generated content” content disclosure when:</p>
|
||||
<ol type="a">
|
||||
<li>a substantial portion of the project's code is a product of AI output.</li>
|
||||
<li>
|
||||
the project includes any assets that are primarily or entirely a product of AI output.
|
||||
</li>
|
||||
<li>the project's design or functionality relies on the use of generative AI.</li>
|
||||
<li>
|
||||
any element of the project's page such as description or publishing relies on generative AI.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 id="prohibited-usage-of-ai">6.2. Prohibited AI generated content</h3>
|
||||
|
||||
<ol type="a">
|
||||
<li>
|
||||
No images uploaded to a gallery, icon, description, or any other part of a project page may
|
||||
be created or derived from generative AI output. Any such images may be removed.
|
||||
</li>
|
||||
<li>
|
||||
Projects may not be published publicly if the contents are primarily or entirely a product
|
||||
of AI output.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.markdown-body h2 {
|
||||
@apply mb-3 mt-5 pb-1 pt-0 text-xl font-semibold text-contrast;
|
||||
}
|
||||
.markdown-body h3 {
|
||||
@apply mb-1 mt-5 p-0 text-lg font-semibold text-contrast;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
@apply !font-semibold !text-brand !no-underline hover:!text-brand hover:!underline;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
@apply leading-normal;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.markdown-body ol ol {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
@apply mb-1;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:on-hide="handleHide"
|
||||
:on-after-hide="handleAfterHide"
|
||||
:closable="false"
|
||||
fade="warning"
|
||||
max-width="544px"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p class="mt-0 mb-4 leading-normal">
|
||||
{{ formatMessage(messages.body1) }}
|
||||
</p>
|
||||
<p class="mt-0 mb-4 leading-normal">
|
||||
{{ formatMessage(messages.body2) }}
|
||||
</p>
|
||||
<p class="mt-0 mb-4 leading-normal">
|
||||
{{ formatMessage(messages.body3) }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
|
||||
<Button type="colored" color="brand" native-type="button" @click="acknowledge">
|
||||
<CheckCircleIcon class="size-4" />
|
||||
{{ formatMessage(commonMessages.iUnderstandButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@modrinth/assets'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: [dontShowAgain: boolean]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = useTemplateRef('modal')
|
||||
const dontShowAgain = ref(false)
|
||||
let pendingDontShowAgain = false
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'search.photosensitivity-warning-modal.title',
|
||||
defaultMessage: 'Results are not guaranteed to be safe',
|
||||
},
|
||||
body1: {
|
||||
id: 'search.photosensitivity-warning-modal.body.1',
|
||||
defaultMessage:
|
||||
'We cannot guarantee that all content on Modrinth has been labeled appropriately.',
|
||||
},
|
||||
body2: {
|
||||
id: 'search.photosensitivity-warning-modal.body.2',
|
||||
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.',
|
||||
},
|
||||
body3: {
|
||||
id: 'search.photosensitivity-warning-modal.body.3',
|
||||
defaultMessage: 'Using any content on Modrinth is at your own risk. Please stay safe! 💚',
|
||||
},
|
||||
dontShowAgain: {
|
||||
id: 'search.photosensitivity-warning-modal.dont-show-again',
|
||||
defaultMessage: "Don't show this again",
|
||||
},
|
||||
})
|
||||
|
||||
function show() {
|
||||
dontShowAgain.value = false
|
||||
pendingDontShowAgain = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
pendingDontShowAgain = dontShowAgain.value
|
||||
}
|
||||
|
||||
function handleAfterHide() {
|
||||
emit('dismiss', pendingDontShowAgain)
|
||||
dontShowAgain.value = false
|
||||
pendingDontShowAgain = false
|
||||
}
|
||||
|
||||
function acknowledge() {
|
||||
pendingDontShowAgain = dontShowAgain.value
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@ export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
export { default as NewModal } from './NewModal.vue'
|
||||
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
|
||||
export { default as OpenInAppModal } from './OpenInAppModal.vue'
|
||||
export { default as PhotosensitivityWarningModal } from './PhotosensitivityWarningModal.vue'
|
||||
export { default as ShareModal } from './ShareModal.vue'
|
||||
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
|
||||
export { default as TabbedModal } from './TabbedModal.vue'
|
||||
|
||||
@@ -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="info" :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>
|
||||
|
||||
@@ -13,7 +13,89 @@
|
||||
<div class="markdown-body" v-html="licenseHtml" />
|
||||
</NewModal>
|
||||
<h2 class="text-lg m-0">{{ formatMessage(commonMessages.detailsLabel) }}</h2>
|
||||
<div class="flex flex-col gap-3 [&>div]:flex [&>div]:gap-2 [&>div]:items-center">
|
||||
<div
|
||||
class="flex flex-col gap-3 [&>div>svg]:shrink-0 [&>div>svg]:mt-[1px] [&>div]:flex [&>div]:gap-2 [&>div]:items-start"
|
||||
>
|
||||
<div v-if="photosensitivityDisclosure" class="text-orange">
|
||||
<EyeIcon aria-hidden="true" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>
|
||||
{{ capitalizeString(formatMessage(messages.photosensitivityTitle)) }}
|
||||
</span>
|
||||
<span v-if="photosensitivityDisclosure.note" class="text-sm text-secondary">
|
||||
{{ photosensitivityDisclosure.note }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="aiDisclosure">
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>
|
||||
{{ capitalizeString(aiGeneratedLabel) }}
|
||||
</span>
|
||||
<span v-if="aiDisclosure.note" class="text-sm text-secondary">
|
||||
{{ aiDisclosure.note }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="advertisingDisclosure">
|
||||
<MegaphoneIcon aria-hidden="true" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>
|
||||
{{ capitalizeString(formatMessage(messages.advertisingTitle)) }}
|
||||
</span>
|
||||
<span v-if="advertisingDisclosure.note" class="text-sm text-secondary">
|
||||
{{ advertisingDisclosure.note }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="paidFeaturesDisclosure">
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>
|
||||
{{ capitalizeString(formatMessage(messages.paidFeatures)) }}
|
||||
</span>
|
||||
<span
|
||||
v-for="(feature, index) in paidFeaturesDisclosure.features"
|
||||
:key="`${feature}-${index}`"
|
||||
class="text-sm text-secondary"
|
||||
>
|
||||
{{ feature }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="telemetryDisclosure">
|
||||
<RadioTowerIcon aria-hidden="true" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>
|
||||
{{
|
||||
capitalizeString(
|
||||
formatMessage(messages.telemetryTitle, {
|
||||
consent: telemetryDisclosure.consent,
|
||||
}),
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-for="(entry, index) in telemetryDisclosure.data_collected"
|
||||
:key="`${entry}-${index}`"
|
||||
class="text-sm text-secondary"
|
||||
>
|
||||
{{ entry }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemInteractionsDisclosure">
|
||||
<CircuitBoardIcon aria-hidden="true" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span>
|
||||
{{ capitalizeString(formatMessage(messages.systemInteractionsTitle)) }}
|
||||
</span>
|
||||
<span v-if="systemInteractionsDisclosure.note" class="text-sm text-secondary">
|
||||
{{ systemInteractionsDisclosure.note }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hideLicense">
|
||||
<BookTextIcon aria-hidden="true" />
|
||||
<div>
|
||||
@@ -41,6 +123,32 @@
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="derivativeWorkDisclosure">
|
||||
<GitForkIcon aria-hidden="true" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
{{ capitalizeString(formatMessage(messages.derivativeWork)) }}
|
||||
</span>
|
||||
<div
|
||||
v-for="(source, index) in derivativeWorkDisclosure.sources"
|
||||
:key="`${source.label}-${index}`"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<a
|
||||
v-if="source.link"
|
||||
:href="source.link"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
class="text-blue text-sm flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{{ source.label }}
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
<span v-else class="text-sm">{{ source.label }}</span>
|
||||
<span v-if="source.note" class="text-sm text-secondary">{{ source.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showFollowers">
|
||||
<HeartIcon aria-hidden="true" />
|
||||
<div>
|
||||
@@ -94,21 +202,37 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { BookTextIcon, CalendarIcon, HeartIcon, ScaleIcon, VersionIcon } from '@modrinth/assets'
|
||||
import {
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
CircleDollarSignIcon,
|
||||
CircuitBoardIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
GitForkIcon,
|
||||
HeartIcon,
|
||||
MegaphoneIcon,
|
||||
RadioTowerIcon,
|
||||
ScaleIcon,
|
||||
SparklesIcon,
|
||||
VersionIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { capitalizeString, renderString } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useRelativeTime } from '../../composables'
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../providers'
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import { getActiveDisclosures } from '../../utils/disclosures'
|
||||
import { Avatar, IntlFormatted } from '../base'
|
||||
import { NewModal } from '../modal'
|
||||
|
||||
const LICENSE_STALE_TIME = 1000 * 60 * 10
|
||||
const DISCLOSURE_STALE_TIME = 1000 * 60 * 5
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
@@ -116,6 +240,8 @@ const formatDateTime = useFormatDateTime({
|
||||
dateStyle: 'long',
|
||||
})
|
||||
|
||||
const AI_USE_ORDER: Labrinth.Projects.v3.AiUsage[] = ['code', 'assets', 'text', 'functionality']
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v2.Project
|
||||
linkTarget: string
|
||||
@@ -143,6 +269,98 @@ const messages = defineMessages({
|
||||
id: 'project.license.loading',
|
||||
defaultMessage: 'Loading license text...',
|
||||
},
|
||||
advertisingTitle: {
|
||||
id: 'project.disclosure.advertising.title',
|
||||
defaultMessage: 'Contains advertising',
|
||||
},
|
||||
paidFeatures: {
|
||||
id: 'project.disclosure.paid-features.title',
|
||||
defaultMessage: 'Contains paid features',
|
||||
},
|
||||
aiGeneratedContent: {
|
||||
id: 'project.disclosure.ai-generated-content.title',
|
||||
defaultMessage: 'Contains AI-generated {types}',
|
||||
},
|
||||
derivativeWork: {
|
||||
id: 'project.disclosure.derivative-work.title',
|
||||
defaultMessage: 'This is a derivative work of:',
|
||||
},
|
||||
telemetryTitle: {
|
||||
id: 'project.disclosure.telemetry.title',
|
||||
defaultMessage:
|
||||
'Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} always_active {always-active telemetry} other {telemetry}}',
|
||||
},
|
||||
photosensitivityTitle: {
|
||||
id: 'project.disclosure.photosensitivity.title',
|
||||
defaultMessage: 'Photosensitivity warning',
|
||||
},
|
||||
systemInteractionsTitle: {
|
||||
id: 'project.disclosure.system-interactions.title',
|
||||
defaultMessage: 'Contains external system interactions',
|
||||
},
|
||||
})
|
||||
|
||||
const { data: disclosuresResponse } = useQuery({
|
||||
queryKey: computed(() => ['project', 'disclosures', 'v3', props.project.id] as const),
|
||||
queryFn: () => labrinth.projects_v3.getDisclosures(props.project.id),
|
||||
staleTime: DISCLOSURE_STALE_TIME,
|
||||
})
|
||||
|
||||
const disclosures = computed(() => getActiveDisclosures(disclosuresResponse.value?.disclosures))
|
||||
|
||||
function findDisclosure<T extends Labrinth.Projects.v3.ProjectDisclosureType>(type: T) {
|
||||
return disclosures.value.find(
|
||||
(d): d is Labrinth.Projects.v3.ProjectDisclosureOf<T> => d.type === type,
|
||||
)
|
||||
}
|
||||
|
||||
const aiDisclosure = computed(() => findDisclosure('ai_content'))
|
||||
const advertisingDisclosure = computed(() => findDisclosure('advertisements'))
|
||||
const paidFeaturesDisclosure = computed(() => findDisclosure('paid_features'))
|
||||
const telemetryDisclosure = computed(() => findDisclosure('telemetry'))
|
||||
const derivativeWorkDisclosure = computed(() => findDisclosure('derivative_work'))
|
||||
const photosensitivityDisclosure = computed(() => findDisclosure('epilepsy_triggers'))
|
||||
const systemInteractionsDisclosure = computed(() => findDisclosure('system_interactions'))
|
||||
|
||||
const aiUseLabels = {
|
||||
code: defineMessage({
|
||||
id: 'project.disclosure.ai-generated-content.use.code',
|
||||
defaultMessage: 'code',
|
||||
}),
|
||||
assets: defineMessage({
|
||||
id: 'project.disclosure.ai-generated-content.use.assets',
|
||||
defaultMessage: 'assets',
|
||||
}),
|
||||
text: defineMessage({
|
||||
id: 'project.disclosure.ai-generated-content.use.text',
|
||||
defaultMessage: 'text',
|
||||
}),
|
||||
functionality: defineMessage({
|
||||
id: 'project.disclosure.ai-generated-content.use.functionality',
|
||||
defaultMessage: 'functionality',
|
||||
}),
|
||||
content: defineMessage({
|
||||
id: 'project.disclosure.ai-generated-content.use.content',
|
||||
defaultMessage: 'content',
|
||||
}),
|
||||
} as const
|
||||
|
||||
const aiGeneratedLabel = computed(() => {
|
||||
const disclosure = aiDisclosure.value
|
||||
if (!disclosure) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const orderedUses = AI_USE_ORDER.filter((use) => disclosure.uses.includes(use))
|
||||
const types =
|
||||
orderedUses.length === 0
|
||||
? formatMessage(aiUseLabels.content)
|
||||
: new Intl.ListFormat(locale.value, {
|
||||
style: 'long',
|
||||
type: 'conjunction',
|
||||
}).format(orderedUses.map((use) => formatMessage(aiUseLabels[use])))
|
||||
|
||||
return formatMessage(messages.aiGeneratedContent, { types })
|
||||
})
|
||||
|
||||
const createdDate = computed(() =>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -40,7 +40,12 @@ import { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
|
||||
import {
|
||||
type FilterOption,
|
||||
type FilterType,
|
||||
type FilterValue,
|
||||
flattenFilterOptions,
|
||||
} from '../../utils/search'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -73,7 +78,7 @@ function filterMatches(type: FilterType, option: FilterOption, list: FilterValue
|
||||
|
||||
const items: ComputedRef<Item[]> = computed(() => {
|
||||
return props.filters.flatMap((type) =>
|
||||
type.options
|
||||
flattenFilterOptions(type.options)
|
||||
.filter(
|
||||
(option) =>
|
||||
filterMatches(type, option, selectedFilters.value) ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="search-filter-option group flex gap-1 items-center">
|
||||
<button
|
||||
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand [&>svg.ban-icon]:hover:text-red [&>svg.ban-icon]:focus-visible:text-red'}`"
|
||||
:class="`flex border-none cursor-pointer flex-1 min-w-0 items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand [&>svg.ban-icon]:hover:text-red [&>svg.ban-icon]:focus-visible:text-red'}`"
|
||||
@click="() => emit(primaryAction === 'exclude' ? 'toggleExclude' : 'toggle', option)"
|
||||
>
|
||||
<slot> </slot>
|
||||
@@ -29,11 +29,33 @@
|
||||
>
|
||||
<BanIcon class="filter-action-icon h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="hasSubOptions"
|
||||
v-tooltip="
|
||||
expanded
|
||||
? formatMessage(messages.collapseSubOptionsTooltip)
|
||||
: formatMessage(messages.expandSubOptionsTooltip)
|
||||
"
|
||||
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary transition-all hover:bg-button-bg hover:text-contrast focus-visible:bg-button-bg focus-visible:text-contrast active:scale-[0.96]"
|
||||
:aria-expanded="expanded"
|
||||
:aria-label="
|
||||
expanded
|
||||
? formatMessage(messages.collapseSubOptionsTooltip)
|
||||
: formatMessage(messages.expandSubOptionsTooltip)
|
||||
"
|
||||
@click.stop="emit('toggleExpand')"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="h-4 w-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': expanded }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BanIcon, CheckIcon } from '@modrinth/assets'
|
||||
import { BanIcon, CheckIcon, DropdownIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
@@ -45,9 +67,13 @@ const props = withDefaults(
|
||||
included: boolean
|
||||
excluded: boolean
|
||||
supports?: FilterMode[]
|
||||
hasSubOptions?: boolean
|
||||
expanded?: boolean
|
||||
}>(),
|
||||
{
|
||||
supports: () => ['include'],
|
||||
hasSubOptions: false,
|
||||
expanded: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -63,6 +89,7 @@ const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
toggle: [option: FilterOption]
|
||||
toggleExclude: [option: FilterOption]
|
||||
toggleExpand: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -70,6 +97,14 @@ const messages = defineMessages({
|
||||
id: 'search.filter.option.exclusion.add.tooltip',
|
||||
defaultMessage: 'Exclude',
|
||||
},
|
||||
expandSubOptionsTooltip: {
|
||||
id: 'search.filter.option.sub_options.expand.tooltip',
|
||||
defaultMessage: 'Show more options',
|
||||
},
|
||||
collapseSubOptionsTooltip: {
|
||||
id: 'search.filter.option.sub_options.collapse.tooltip',
|
||||
defaultMessage: 'Hide more options',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -30,14 +30,14 @@
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<div
|
||||
v-for="option in selectedFilterOptions"
|
||||
:key="`selected-filter-${filterType.id}-${option}`"
|
||||
:key="`selected-filter-${filterType.id}-${option.id}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
{{ option.formatted_name ?? option.id }}
|
||||
</div>
|
||||
<div
|
||||
v-for="option in selectedNegativeFilterOptions"
|
||||
:key="`excluded-filter-${filterType.id}-${option}`"
|
||||
:key="`excluded-filter-${filterType.id}-${option.id}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
<BanIcon class="text-brand-red" /> {{ option.formatted_name ?? option.id }}
|
||||
@@ -98,37 +98,62 @@
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchFilterOption
|
||||
v-for="option in visibleOptions"
|
||||
:key="`${filterType.id}-${option}`"
|
||||
:option="option"
|
||||
:included="isIncluded(option)"
|
||||
:excluded="isExcluded(option)"
|
||||
:supports="filterType.supports"
|
||||
:class="{
|
||||
'mr-3': scrollable,
|
||||
}"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
>
|
||||
<slot name="option" :filter="filterType" :option="option">
|
||||
<span
|
||||
v-if="option.icon"
|
||||
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
|
||||
:style="iconStyle(option)"
|
||||
<template v-for="option in visibleOptions" :key="`${filterType.id}-${option.id}`">
|
||||
<SearchFilterOption
|
||||
:option="option"
|
||||
:included="isIncluded(option)"
|
||||
:excluded="isExcluded(option)"
|
||||
:supports="filterType.supports"
|
||||
:has-sub-options="!!option.sub_options?.length"
|
||||
:expanded="isExpanded(option)"
|
||||
:class="{
|
||||
'mr-3': scrollable,
|
||||
}"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
@toggle-expand="toggleExpand(option)"
|
||||
>
|
||||
<slot name="option" :filter="filterType" :option="option">
|
||||
<span
|
||||
v-if="option.icon"
|
||||
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
|
||||
:style="iconStyle(option)"
|
||||
>
|
||||
<div
|
||||
v-if="typeof option.icon === 'string'"
|
||||
class="h-4 w-4"
|
||||
v-html="option.icon"
|
||||
/>
|
||||
<component :is="option.icon" v-else class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="truncate text-sm" :style="iconStyle(option)">
|
||||
{{ option.formatted_name ?? option.id }}
|
||||
</span>
|
||||
</slot>
|
||||
</SearchFilterOption>
|
||||
<div
|
||||
v-if="option.sub_options?.length && isExpanded(option)"
|
||||
class="ml-4 flex flex-col gap-1"
|
||||
:class="{ 'mr-3': scrollable }"
|
||||
>
|
||||
<SearchFilterOption
|
||||
v-for="subOption in option.sub_options"
|
||||
:key="`${filterType.id}-${subOption.id}`"
|
||||
:option="subOption"
|
||||
:included="isIncluded(subOption)"
|
||||
:excluded="isExcluded(subOption)"
|
||||
:supports="filterType.supports"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
>
|
||||
<div
|
||||
v-if="typeof option.icon === 'string'"
|
||||
class="h-4 w-4"
|
||||
v-html="option.icon"
|
||||
/>
|
||||
<component :is="option.icon" v-else class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="truncate text-sm" :style="iconStyle(option)">
|
||||
{{ option.formatted_name ?? option.id }}
|
||||
</span>
|
||||
</slot>
|
||||
</SearchFilterOption>
|
||||
<slot name="option" :filter="filterType" :option="subOption">
|
||||
<span class="truncate text-sm">
|
||||
{{ subOption.formatted_name ?? subOption.id }}
|
||||
</span>
|
||||
</slot>
|
||||
</SearchFilterOption>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<button
|
||||
v-if="filterType.display === 'expandable'"
|
||||
@@ -180,12 +205,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BanIcon, DropdownIcon, LockOpenIcon, SearchIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
|
||||
import {
|
||||
type FilterOption,
|
||||
type FilterType,
|
||||
type FilterValue,
|
||||
findParentFilterOption,
|
||||
flattenFilterOptions,
|
||||
} from '../../utils/search'
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
|
||||
import SearchFilterGroup from './SearchFilterGroup.vue'
|
||||
@@ -215,22 +246,31 @@ defineOptions({
|
||||
|
||||
const query = ref('')
|
||||
const showMore = ref(false)
|
||||
const expandedOptionIds = ref<string[]>([])
|
||||
|
||||
const accordion = ref<InstanceType<typeof Accordion> | null>()
|
||||
|
||||
const allOptions = computed(() => flattenFilterOptions(props.filterType.options))
|
||||
|
||||
const selectedFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
allOptions.value.filter((option) =>
|
||||
locked.value ? isProvided(option, false) : isIncluded(option),
|
||||
),
|
||||
)
|
||||
const selectedNegativeFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
allOptions.value.filter((option) =>
|
||||
locked.value ? isProvided(option, true) : isExcluded(option),
|
||||
),
|
||||
)
|
||||
const visibleOptions = computed(() =>
|
||||
props.filterType.options
|
||||
.filter((option) => isVisible(option) || isIncluded(option) || isExcluded(option))
|
||||
.filter(
|
||||
(option) =>
|
||||
isVisible(option) ||
|
||||
isIncluded(option) ||
|
||||
isExcluded(option) ||
|
||||
hasSelectedSubOption(option),
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (props.filterType.display === 'expandable') {
|
||||
@@ -309,6 +349,24 @@ function isExcluded(filter: FilterOption) {
|
||||
return selectedFilters.value.some((value) => value.option === filter.id && value.negative)
|
||||
}
|
||||
|
||||
function hasSelectedSubOption(filter: FilterOption) {
|
||||
return (
|
||||
filter.sub_options?.some((subOption) => isIncluded(subOption) || isExcluded(subOption)) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
function isExpanded(filter: FilterOption) {
|
||||
return expandedOptionIds.value.includes(filter.id)
|
||||
}
|
||||
|
||||
function toggleExpand(filter: FilterOption) {
|
||||
if (isExpanded(filter)) {
|
||||
expandedOptionIds.value = expandedOptionIds.value.filter((id) => id !== filter.id)
|
||||
} else {
|
||||
expandedOptionIds.value = [...expandedOptionIds.value, filter.id]
|
||||
}
|
||||
}
|
||||
|
||||
function isVisible(filter: FilterOption) {
|
||||
const filterKey = filter.formatted_name?.toLowerCase() ?? filter.id.toLowerCase()
|
||||
const matchesQuery = !query.value || filterKey.includes(query.value.toLowerCase())
|
||||
@@ -341,7 +399,22 @@ function toggleNegativeFilter(filter: FilterOption) {
|
||||
}
|
||||
|
||||
function setFilter(filter: FilterOption, state: FilterState) {
|
||||
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
|
||||
let newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
|
||||
|
||||
if (state !== 'ignore') {
|
||||
const subOptionIds = new Set(filter.sub_options?.map((subOption) => subOption.id) ?? [])
|
||||
if (subOptionIds.size > 0) {
|
||||
newFilters = newFilters.filter((selected) => !subOptionIds.has(selected.option))
|
||||
}
|
||||
|
||||
const parent = findParentFilterOption(props.filterType.options, filter.id)
|
||||
if (parent) {
|
||||
newFilters = newFilters.filter((selected) => selected.option !== parent.id)
|
||||
if (!expandedOptionIds.value.includes(parent.id)) {
|
||||
expandedOptionIds.value = [...expandedOptionIds.value, parent.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseValues = {
|
||||
type: props.filterType.id,
|
||||
@@ -369,6 +442,18 @@ function clearFilters() {
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedFilters,
|
||||
() => {
|
||||
for (const option of props.filterType.options) {
|
||||
if (hasSelectedSubOption(option) && !expandedOptionIds.value.includes(option.id)) {
|
||||
expandedOptionIds.value = [...expandedOptionIds.value, option.id]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'search.filter.option.search.placeholder',
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
persistenceNote: {
|
||||
id: 'browse.advanced-filters.persistence-note',
|
||||
defaultMessage:
|
||||
'Advanced exclusions are saved across sessions. Opening a shared link with filters will override them.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-2 flex gap-2 px-3 text-sm text-secondary opacity-80">
|
||||
<InfoIcon class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ formatMessage(messages.persistenceNote) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,15 @@ import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import {
|
||||
compatibleAdvancedFilters,
|
||||
getAdvancedOptionIds,
|
||||
getAvailableAdvancedIds,
|
||||
mergeAdvancedPrefs,
|
||||
replaceAdvancedFilters,
|
||||
sameOptionIds,
|
||||
useAdvancedPrefs,
|
||||
} from '#ui/utils/advanced-filter-preferences'
|
||||
import type {
|
||||
EnvironmentSearchOverride,
|
||||
FilterType,
|
||||
@@ -66,6 +75,9 @@ export interface BrowseSearchState {
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
|
||||
linkOverridesAdvancedPrefs: Ref<boolean>
|
||||
applySavedAdvancedPrefs: () => void
|
||||
}
|
||||
|
||||
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
|
||||
@@ -197,6 +209,76 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
const effectiveCurrentFilters = computed(() =>
|
||||
isServerType.value ? serverCurrentFilters.value : currentFilters.value,
|
||||
)
|
||||
const effectiveFilterTypes = computed(() =>
|
||||
isServerType.value ? serverFilterTypes.value : filters.value,
|
||||
)
|
||||
|
||||
const advancedPrefs = useAdvancedPrefs()
|
||||
const linkOverridesAdvancedPrefs = ref(false)
|
||||
const selectedAdvancedIds = computed(() => getAdvancedOptionIds(effectiveCurrentFilters.value))
|
||||
|
||||
function hasSearchQuery(): boolean {
|
||||
return Object.keys(route.query).some((key) => !options.persistentQueryParams.includes(key))
|
||||
}
|
||||
|
||||
function getCompatiblePrefs(): FilterValue[] {
|
||||
return compatibleAdvancedFilters(advancedPrefs.value, effectiveFilterTypes.value)
|
||||
}
|
||||
|
||||
function setEffectiveFilters(nextFilters: FilterValue[]) {
|
||||
if (isServerType.value) {
|
||||
serverCurrentFilters.value = nextFilters
|
||||
} else {
|
||||
currentFilters.value = nextFilters
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdvancedPrefs(prefs: FilterValue[]) {
|
||||
setEffectiveFilters(replaceAdvancedFilters(effectiveCurrentFilters.value, prefs))
|
||||
}
|
||||
|
||||
function syncLinkOverride() {
|
||||
const prefIds = getAdvancedOptionIds(getCompatiblePrefs())
|
||||
linkOverridesAdvancedPrefs.value =
|
||||
prefIds.length > 0 && !sameOptionIds(selectedAdvancedIds.value, prefIds)
|
||||
}
|
||||
|
||||
function initAdvancedPrefs() {
|
||||
const prefs = getCompatiblePrefs()
|
||||
|
||||
if (!hasSearchQuery()) {
|
||||
if (prefs.length > 0) {
|
||||
applyAdvancedPrefs(prefs)
|
||||
}
|
||||
linkOverridesAdvancedPrefs.value = false
|
||||
return
|
||||
}
|
||||
|
||||
syncLinkOverride()
|
||||
}
|
||||
|
||||
function applySavedAdvancedPrefs() {
|
||||
applyAdvancedPrefs(getCompatiblePrefs())
|
||||
linkOverridesAdvancedPrefs.value = false
|
||||
}
|
||||
|
||||
initAdvancedPrefs()
|
||||
|
||||
watch(selectedAdvancedIds, (selected, previous) => {
|
||||
if (previous && sameOptionIds(selected, previous)) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextPrefs = mergeAdvancedPrefs(
|
||||
advancedPrefs.value,
|
||||
selected,
|
||||
getAvailableAdvancedIds(effectiveFilterTypes.value),
|
||||
)
|
||||
if (!sameOptionIds(advancedPrefs.value, nextPrefs)) {
|
||||
advancedPrefs.value = nextPrefs
|
||||
}
|
||||
linkOverridesAdvancedPrefs.value = false
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
@@ -340,6 +422,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
|
||||
effectiveSortTypes.value[0]
|
||||
query.value = ''
|
||||
|
||||
void nextTick(() => {
|
||||
initAdvancedPrefs()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -369,5 +455,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
||||
setPage,
|
||||
clearSearch,
|
||||
onFilterChange,
|
||||
linkOverridesAdvancedPrefs,
|
||||
applySavedAdvancedPrefs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { RotateCounterClockwiseIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import { Button, IconButton } from '#ui/components/base/buttons'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||
@@ -66,6 +67,14 @@ const messages = defineMessages({
|
||||
id: 'browse.no-results',
|
||||
defaultMessage: 'No results found for your query!',
|
||||
},
|
||||
linkOverridingPreferences: {
|
||||
id: 'browse.advanced-filters.link-overriding-preferences',
|
||||
defaultMessage: "This link's filters differ from your saved advanced exclusions",
|
||||
},
|
||||
applySavedPreferences: {
|
||||
id: 'browse.advanced-filters.apply-saved-preferences',
|
||||
defaultMessage: 'Apply saved preferences',
|
||||
},
|
||||
})
|
||||
|
||||
function cardActionType(action: CardAction) {
|
||||
@@ -151,6 +160,21 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
|
||||
@clear="ctx.clearSearch()"
|
||||
/>
|
||||
|
||||
<Admonition
|
||||
v-if="ctx.linkOverridesAdvancedPrefs.value"
|
||||
type="info"
|
||||
:header="formatMessage(messages.linkOverridingPreferences)"
|
||||
inline-actions
|
||||
center-content
|
||||
>
|
||||
<template #actions>
|
||||
<Button type="colored" color="blue" @click="ctx.applySavedAdvancedPrefs()">
|
||||
<RotateCounterClockwiseIcon />
|
||||
{{ formatMessage(messages.applySavedPreferences) }}
|
||||
</Button>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
:model-value="ctx.effectiveCurrentSortType.value"
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface BrowseManagerContext {
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
linkOverridesAdvancedPrefs: Ref<boolean>
|
||||
applySavedAdvancedPrefs: () => void
|
||||
|
||||
getProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => string | RouteLocationRaw
|
||||
getServerProjectLink: (
|
||||
@@ -74,6 +76,7 @@ export interface BrowseManagerContext {
|
||||
serverOnlyLabel?: ComputedRef<string>
|
||||
hiddenFilterTypes?: ComputedRef<string[]>
|
||||
advancedFiltersCollapsed?: Ref<boolean>
|
||||
dismissedPhotosensitivityFilterWarning?: Ref<boolean>
|
||||
onInstalled?: (projectId: string) => void
|
||||
|
||||
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, nextTick, toValue, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { IconButton } from '#ui/components/base/buttons'
|
||||
import Toggle from '#ui/components/base/Toggle.vue'
|
||||
import PhotosensitivityWarningModal from '#ui/components/modal/PhotosensitivityWarningModal.vue'
|
||||
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { useAdvancedPrefs } from '#ui/utils/advanced-filter-preferences'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import AdvancedFiltersPersistenceNote from './components/AdvancedFiltersPersistenceNote.vue'
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const PHOTOSENSITIVITY_FILTER_OPTION = 'epilepsy_triggers'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const advancedPrefs = useAdvancedPrefs()
|
||||
|
||||
const isApp = computed(() => ctx.variant === 'app')
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
|
||||
|
||||
const advancedFiltersCollapsed = computed(() => ctx.advancedFiltersCollapsed?.value ?? true)
|
||||
const photosensitivityWarningModal = useTemplateRef('photosensitivityWarningModal')
|
||||
|
||||
function setAdvancedFiltersCollapsed(collapsed: boolean) {
|
||||
if (ctx.advancedFiltersCollapsed) {
|
||||
@@ -25,6 +32,40 @@ function setAdvancedFiltersCollapsed(collapsed: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPhotosensitivityExclusionSelected(filters: { type: string; option: string }[]) {
|
||||
return filters.some(
|
||||
(filter) => filter.type === 'advanced' && filter.option === PHOTOSENSITIVITY_FILTER_OPTION,
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
selected: isPhotosensitivityExclusionSelected(
|
||||
ctx.isServerType.value ? ctx.serverCurrentFilters.value : ctx.currentFilters.value,
|
||||
),
|
||||
saved: advancedPrefs.value.includes(PHOTOSENSITIVITY_FILTER_OPTION),
|
||||
}),
|
||||
(current, previous) => {
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
|
||||
const selected = current.selected && !previous.selected
|
||||
const alreadySaved = previous.saved
|
||||
const dismissed = ctx.dismissedPhotosensitivityFilterWarning?.value
|
||||
|
||||
if (selected && !alreadySaved && !dismissed) {
|
||||
nextTick(() => photosensitivityWarningModal.value?.show())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function onPhotosensitivityWarningDismiss(dontShowAgain: boolean) {
|
||||
if (dontShowAgain && ctx.dismissedPhotosensitivityFilterWarning) {
|
||||
ctx.dismissedPhotosensitivityFilterWarning.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function closeFiltersMenu() {
|
||||
if (ctx.filtersMenuOpen) {
|
||||
ctx.filtersMenuOpen.value = false
|
||||
@@ -88,6 +129,11 @@ function getFilterOpenByDefault(filterId: string): boolean {
|
||||
<template>
|
||||
<slot name="prepend" />
|
||||
|
||||
<PhotosensitivityWarningModal
|
||||
ref="photosensitivityWarningModal"
|
||||
@dismiss="onPhotosensitivityWarningDismiss"
|
||||
/>
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen?.value" class="fixed inset-0 z-40 bg-bg" />
|
||||
|
||||
<div
|
||||
@@ -173,12 +219,17 @@ 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'">
|
||||
{{ filterType.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
<template v-if="filterType.id === 'advanced'" #prefix>
|
||||
<AdvancedFiltersPersistenceNote />
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -205,8 +256,11 @@ function getFilterOpenByDefault(filterId: string): boolean {
|
||||
{{ filter.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
<template v-if="filter.id === 'advanced'" #prefix>
|
||||
<AdvancedFiltersPersistenceNote />
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
v-else-if="
|
||||
lockedMessages?.gameVersionShaderMessage &&
|
||||
ctx.projectType.value === 'shader' &&
|
||||
filter.id === 'game_version'
|
||||
@@ -215,7 +269,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
|
||||
>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span>{{ lockedMessages.gameVersionShaderMessage }}</span>
|
||||
<span>{{ lockedMessages?.gameVersionShaderMessage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="lockedMessages?.gameVersion" #locked-game_version>
|
||||
|
||||
@@ -3791,15 +3791,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Erweitert"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Datenpakete ausschließen"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Mods ausschließen"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Plugins ausschließen"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Umgebung"
|
||||
},
|
||||
|
||||
@@ -3791,15 +3791,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Erweitert"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Datenpakete ausschließen"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Mods ausschließen"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Plugins ausschließen"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Umgebung"
|
||||
},
|
||||
|
||||
@@ -92,6 +92,15 @@
|
||||
"billing.resubscribe-modal.title": {
|
||||
"defaultMessage": "Resubscribe to Server"
|
||||
},
|
||||
"browse.advanced-filters.apply-saved-preferences": {
|
||||
"defaultMessage": "Apply saved preferences"
|
||||
},
|
||||
"browse.advanced-filters.link-overriding-preferences": {
|
||||
"defaultMessage": "This link's filters differ from your saved advanced exclusions"
|
||||
},
|
||||
"browse.advanced-filters.persistence-note": {
|
||||
"defaultMessage": "Advanced exclusions are saved across sessions. Opening a shared link with filters will override them."
|
||||
},
|
||||
"browse.filter-results": {
|
||||
"defaultMessage": "Filter results..."
|
||||
},
|
||||
@@ -131,6 +140,9 @@
|
||||
"button.accept": {
|
||||
"defaultMessage": "Accept"
|
||||
},
|
||||
"button.add-another": {
|
||||
"defaultMessage": "Add another"
|
||||
},
|
||||
"button.add-server-to-instance": {
|
||||
"defaultMessage": "Add server to instance"
|
||||
},
|
||||
@@ -203,6 +215,9 @@
|
||||
"button.hide-snapshots": {
|
||||
"defaultMessage": "Hide snapshots"
|
||||
},
|
||||
"button.i-understand": {
|
||||
"defaultMessage": "I understand"
|
||||
},
|
||||
"button.install": {
|
||||
"defaultMessage": "Install"
|
||||
},
|
||||
@@ -1058,6 +1073,9 @@
|
||||
"creation-flow.title.set-up-server": {
|
||||
"defaultMessage": "Set up server"
|
||||
},
|
||||
"empty-state.upload-versions.description": {
|
||||
"defaultMessage": "Come back once you've uploaded your versions."
|
||||
},
|
||||
"external-files.permissions-card.add-files-modal.confirm": {
|
||||
"defaultMessage": "{count, plural, one {Add file} other {Add files}}"
|
||||
},
|
||||
@@ -2363,6 +2381,9 @@
|
||||
"label.error": {
|
||||
"defaultMessage": "Error"
|
||||
},
|
||||
"label.explanation": {
|
||||
"defaultMessage": "Explanation"
|
||||
},
|
||||
"label.extract-failed": {
|
||||
"defaultMessage": "Extract failed"
|
||||
},
|
||||
@@ -3344,6 +3365,42 @@
|
||||
"project.about.tags.title": {
|
||||
"defaultMessage": "Tags"
|
||||
},
|
||||
"project.disclosure.advertising.title": {
|
||||
"defaultMessage": "Contains advertising"
|
||||
},
|
||||
"project.disclosure.ai-generated-content.title": {
|
||||
"defaultMessage": "Contains AI-generated {types}"
|
||||
},
|
||||
"project.disclosure.ai-generated-content.use.assets": {
|
||||
"defaultMessage": "assets"
|
||||
},
|
||||
"project.disclosure.ai-generated-content.use.code": {
|
||||
"defaultMessage": "code"
|
||||
},
|
||||
"project.disclosure.ai-generated-content.use.content": {
|
||||
"defaultMessage": "content"
|
||||
},
|
||||
"project.disclosure.ai-generated-content.use.functionality": {
|
||||
"defaultMessage": "functionality"
|
||||
},
|
||||
"project.disclosure.ai-generated-content.use.text": {
|
||||
"defaultMessage": "text"
|
||||
},
|
||||
"project.disclosure.derivative-work.title": {
|
||||
"defaultMessage": "This is a derivative work of:"
|
||||
},
|
||||
"project.disclosure.paid-features.title": {
|
||||
"defaultMessage": "Contains paid features"
|
||||
},
|
||||
"project.disclosure.photosensitivity.title": {
|
||||
"defaultMessage": "Photosensitivity warning"
|
||||
},
|
||||
"project.disclosure.system-interactions.title": {
|
||||
"defaultMessage": "Contains external system interactions"
|
||||
},
|
||||
"project.disclosure.telemetry.title": {
|
||||
"defaultMessage": "Contains {consent, select, opt_in {opt-in telemetry} opt_out {opt-out telemetry} always_active {always-active telemetry} other {telemetry}}"
|
||||
},
|
||||
"project.download-count-tooltip": {
|
||||
"defaultMessage": "{count, number} {count, plural, one {download} other {downloads}}"
|
||||
},
|
||||
@@ -3707,6 +3764,30 @@
|
||||
"project.settings.description.title": {
|
||||
"defaultMessage": "Description"
|
||||
},
|
||||
"project.settings.disclosures.ai.types-assets": {
|
||||
"defaultMessage": "Assets"
|
||||
},
|
||||
"project.settings.disclosures.ai.types-code": {
|
||||
"defaultMessage": "Code"
|
||||
},
|
||||
"project.settings.disclosures.ai.types-functionality": {
|
||||
"defaultMessage": "Functionality"
|
||||
},
|
||||
"project.settings.disclosures.ai.types-text": {
|
||||
"defaultMessage": "Text"
|
||||
},
|
||||
"project.settings.disclosures.telemetry.consent-always-active": {
|
||||
"defaultMessage": "Always active"
|
||||
},
|
||||
"project.settings.disclosures.telemetry.consent-opt-in": {
|
||||
"defaultMessage": "Opt-in"
|
||||
},
|
||||
"project.settings.disclosures.telemetry.consent-opt-out": {
|
||||
"defaultMessage": "Opt-out"
|
||||
},
|
||||
"project.settings.disclosures.title": {
|
||||
"defaultMessage": "Disclosures"
|
||||
},
|
||||
"project.settings.environment.client_and_server.description": {
|
||||
"defaultMessage": "Has some functionality on both the client and server, even if only partially."
|
||||
},
|
||||
@@ -3836,6 +3917,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"
|
||||
},
|
||||
@@ -3902,6 +3989,24 @@
|
||||
"report.item-type.version": {
|
||||
"defaultMessage": "version"
|
||||
},
|
||||
"report.type.copyright": {
|
||||
"defaultMessage": "Reuploaded work"
|
||||
},
|
||||
"report.type.inappropriate": {
|
||||
"defaultMessage": "Inappropriate"
|
||||
},
|
||||
"report.type.malicious": {
|
||||
"defaultMessage": "Malicious"
|
||||
},
|
||||
"report.type.missing-disclosure": {
|
||||
"defaultMessage": "Missing or incorrect disclosure"
|
||||
},
|
||||
"report.type.name-squatting": {
|
||||
"defaultMessage": "Name squatting"
|
||||
},
|
||||
"report.type.spam": {
|
||||
"defaultMessage": "Spam"
|
||||
},
|
||||
"s.bg": {
|
||||
"defaultMessage": "Background task running"
|
||||
},
|
||||
@@ -3935,17 +4040,41 @@
|
||||
"search.filter.option.show_more": {
|
||||
"defaultMessage": "Show more"
|
||||
},
|
||||
"search.filter.option.sub_options.collapse.tooltip": {
|
||||
"defaultMessage": "Hide more options"
|
||||
},
|
||||
"search.filter.option.sub_options.expand.tooltip": {
|
||||
"defaultMessage": "Show more options"
|
||||
},
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Advanced"
|
||||
"defaultMessage": "Advanced exclusions"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Exclude data packs"
|
||||
"search.filter_type.advanced.disclosure.advertisements": {
|
||||
"defaultMessage": "Advertisements"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Exclude mods"
|
||||
"search.filter_type.advanced.disclosure.ai_content": {
|
||||
"defaultMessage": "AI-generated content"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Exclude plugins"
|
||||
"search.filter_type.advanced.disclosure.ai_content.usage": {
|
||||
"defaultMessage": "AI {usage}"
|
||||
},
|
||||
"search.filter_type.advanced.disclosure.archived": {
|
||||
"defaultMessage": "Archived"
|
||||
},
|
||||
"search.filter_type.advanced.disclosure.derivative_work": {
|
||||
"defaultMessage": "Derivative content"
|
||||
},
|
||||
"search.filter_type.advanced.disclosure.epilepsy_triggers": {
|
||||
"defaultMessage": "Photosensitivity triggers"
|
||||
},
|
||||
"search.filter_type.advanced.disclosure.paid_features": {
|
||||
"defaultMessage": "Paid features"
|
||||
},
|
||||
"search.filter_type.advanced.disclosure.system_interactions": {
|
||||
"defaultMessage": "External system interactions"
|
||||
},
|
||||
"search.filter_type.advanced.disclosure.telemetry": {
|
||||
"defaultMessage": "Telemetry"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Environment"
|
||||
@@ -3998,6 +4127,21 @@
|
||||
"search.filter_type.shader_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"search.photosensitivity-warning-modal.body.1": {
|
||||
"defaultMessage": "We cannot guarantee that all content on Modrinth has been labeled appropriately."
|
||||
},
|
||||
"search.photosensitivity-warning-modal.body.2": {
|
||||
"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! 💚"
|
||||
},
|
||||
"search.photosensitivity-warning-modal.dont-show-again": {
|
||||
"defaultMessage": "Don't show this again"
|
||||
},
|
||||
"search.photosensitivity-warning-modal.title": {
|
||||
"defaultMessage": "Results are not guaranteed to be safe"
|
||||
},
|
||||
"search.server_content_type.modpack": {
|
||||
"defaultMessage": "Modded"
|
||||
},
|
||||
@@ -5339,6 +5483,9 @@
|
||||
"settings.feature-flags.title": {
|
||||
"defaultMessage": "Feature flags"
|
||||
},
|
||||
"settings.form.group.optional": {
|
||||
"defaultMessage": "(optional)"
|
||||
},
|
||||
"settings.language.categories.default": {
|
||||
"defaultMessage": "Standard languages"
|
||||
},
|
||||
|
||||
@@ -3791,15 +3791,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Avanzado"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Excluir paquetes de datos"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Excluir mods"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Excluir complementos"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Entorno"
|
||||
},
|
||||
|
||||
@@ -3788,15 +3788,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Avanzado"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Excluir paquetes de datos"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Excluir mods"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Excluir plugins"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Entorno"
|
||||
},
|
||||
|
||||
@@ -3785,15 +3785,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Avancé"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Exclure les data packs"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Exclure les mods"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Exclure les plugins"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Environnement"
|
||||
},
|
||||
|
||||
@@ -3749,15 +3749,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Avanzate"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Escludi pacchetti di dati"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Escludi mod"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Escludi plugin"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Ambiente"
|
||||
},
|
||||
|
||||
@@ -3710,9 +3710,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "もっと見る"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "データパックを除く"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "環境"
|
||||
},
|
||||
|
||||
@@ -3776,15 +3776,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "고급"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "데이터 팩 제외"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "모드 제외"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "플러그인 제외"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "실행 환경"
|
||||
},
|
||||
|
||||
@@ -3782,15 +3782,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Gevorderd"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Gegevenspakketten uitsluiten"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Mods uitsluiten"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Plug-ins uitsluiten"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Omgeving"
|
||||
},
|
||||
|
||||
@@ -3758,15 +3758,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Zaawansowane"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Wyklucz paczki danych"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Wyklucz mody"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Wyklucz pluginy"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Środowisko"
|
||||
},
|
||||
|
||||
@@ -3791,15 +3791,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Avançado"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Excluir pacotes de dados"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Excluir mods"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Excluir plugins"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Ambiente"
|
||||
},
|
||||
|
||||
@@ -3704,15 +3704,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Расширенные"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Исключать наборы данных"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Исключать моды"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Исключать плагины"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Среда"
|
||||
},
|
||||
|
||||
@@ -3560,15 +3560,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Napredno"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Isključi data pakete"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Isključi modove"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Isključi plugine"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Okruže"
|
||||
},
|
||||
|
||||
@@ -3416,12 +3416,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Avancerat"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Exkludera datapaket"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Exkludera moddar"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Miljö"
|
||||
},
|
||||
|
||||
@@ -3617,15 +3617,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Gelişmiş"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Veri paketlerini hariç tut"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Modları hariç tut"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Eklentileri hariç tut"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Ortam"
|
||||
},
|
||||
|
||||
@@ -3746,15 +3746,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "Більше"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "Виключити пакети даних"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "Виключити моди"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "Виключити плаґіни"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Середовище"
|
||||
},
|
||||
|
||||
@@ -3791,15 +3791,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "高级"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "排除数据包"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "排除模组"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "排除插件"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "运行环境"
|
||||
},
|
||||
|
||||
@@ -3791,15 +3791,6 @@
|
||||
"search.filter_type.advanced": {
|
||||
"defaultMessage": "進階篩選"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_datapack": {
|
||||
"defaultMessage": "排除資料包"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_mod": {
|
||||
"defaultMessage": "排除模組"
|
||||
},
|
||||
"search.filter_type.advanced.exclude_plugin": {
|
||||
"defaultMessage": "排除插件"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "環境"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { type FilterType, type FilterValue, findFilterOption, flattenFilterOptions } from './search'
|
||||
|
||||
export const ADVANCED_PREFS_KEY = 'modrinth-advanced-exclusion-filters'
|
||||
|
||||
export function useAdvancedPrefs(): Ref<string[]> {
|
||||
return useStorage<string[]>(ADVANCED_PREFS_KEY, [])
|
||||
}
|
||||
|
||||
export function getAdvancedOptionIds(filters: readonly FilterValue[]): string[] {
|
||||
return filters
|
||||
.filter((filter) => filter.type === 'advanced')
|
||||
.map((filter) => filter.option)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
export function sameOptionIds(a: readonly string[], b: readonly string[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
const other = new Set(b)
|
||||
return a.every((id) => other.has(id))
|
||||
}
|
||||
|
||||
export function getAvailableAdvancedIds(filterTypes: readonly FilterType[]): ReadonlySet<string> {
|
||||
const advancedType = filterTypes.find((filterType) => filterType.id === 'advanced')
|
||||
if (!advancedType) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(flattenFilterOptions(advancedType.options).map((option) => option.id))
|
||||
}
|
||||
|
||||
export function compatibleAdvancedFilters(
|
||||
prefs: readonly string[],
|
||||
filterTypes: readonly FilterType[],
|
||||
): FilterValue[] {
|
||||
const advancedType = filterTypes.find((filterType) => filterType.id === 'advanced')
|
||||
if (!advancedType) {
|
||||
return []
|
||||
}
|
||||
|
||||
return prefs
|
||||
.filter((id) => findFilterOption(advancedType.options, id))
|
||||
.map((id) => ({
|
||||
type: 'advanced',
|
||||
option: id,
|
||||
negative: true,
|
||||
}))
|
||||
}
|
||||
|
||||
export function replaceAdvancedFilters(
|
||||
filters: readonly FilterValue[],
|
||||
advancedFilters: readonly FilterValue[],
|
||||
): FilterValue[] {
|
||||
return [...filters.filter((filter) => filter.type !== 'advanced'), ...advancedFilters]
|
||||
}
|
||||
|
||||
export function mergeAdvancedPrefs(
|
||||
prefs: readonly string[],
|
||||
selected: readonly string[],
|
||||
available: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const selectedSet = new Set(selected)
|
||||
const next = prefs.filter((id) => !available.has(id) || selectedSet.has(id))
|
||||
|
||||
for (const id of selectedSet) {
|
||||
if (!next.includes(id)) {
|
||||
next.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return next.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
|
||||
import { defineMessage, defineMessages, type MessageDescriptor } from '../composables/i18n'
|
||||
|
||||
@@ -35,6 +36,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'project-type.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
addAnotherButton: {
|
||||
id: 'button.add-another',
|
||||
defaultMessage: 'Add another',
|
||||
},
|
||||
addServerToInstanceButton: {
|
||||
id: 'button.add-server-to-instance',
|
||||
defaultMessage: 'Add server to instance',
|
||||
@@ -143,6 +148,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'notification.error.title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
explanationLabel: {
|
||||
id: 'label.explanation',
|
||||
defaultMessage: 'Explanation',
|
||||
},
|
||||
filterByLabel: {
|
||||
id: 'label.filter-by',
|
||||
defaultMessage: 'Filter by',
|
||||
@@ -167,6 +176,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'input.view.grid',
|
||||
defaultMessage: 'Grid view',
|
||||
},
|
||||
iUnderstandButton: {
|
||||
id: 'button.i-understand',
|
||||
defaultMessage: 'I understand',
|
||||
},
|
||||
listInputView: {
|
||||
id: 'input.view.list',
|
||||
defaultMessage: 'Rows view',
|
||||
@@ -577,6 +590,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.upload-failed',
|
||||
defaultMessage: 'Upload failed',
|
||||
},
|
||||
uploadVersionsEmptyStateDescription: {
|
||||
id: 'empty-state.upload-versions.description',
|
||||
defaultMessage: `Come back once you've uploaded your versions.`,
|
||||
},
|
||||
renameFailedLabel: {
|
||||
id: 'label.rename-failed',
|
||||
defaultMessage: 'Rename failed',
|
||||
@@ -1007,6 +1024,43 @@ export function formatReportItemType(
|
||||
return formatMessage(reportItemTypeMessages[key])
|
||||
}
|
||||
|
||||
export const reportTypeMessages = defineMessages({
|
||||
spam: {
|
||||
id: 'report.type.spam',
|
||||
defaultMessage: 'Spam',
|
||||
},
|
||||
copyright: {
|
||||
id: 'report.type.copyright',
|
||||
defaultMessage: 'Reuploaded work',
|
||||
},
|
||||
inappropriate: {
|
||||
id: 'report.type.inappropriate',
|
||||
defaultMessage: 'Inappropriate',
|
||||
},
|
||||
malicious: {
|
||||
id: 'report.type.malicious',
|
||||
defaultMessage: 'Malicious',
|
||||
},
|
||||
'name-squatting': {
|
||||
id: 'report.type.name-squatting',
|
||||
defaultMessage: 'Name squatting',
|
||||
},
|
||||
'missing-disclosure': {
|
||||
id: 'report.type.missing-disclosure',
|
||||
defaultMessage: 'Missing or incorrect disclosure',
|
||||
},
|
||||
})
|
||||
|
||||
export function formatReportType(formatMessage: FormatMessage, type: string | undefined): string {
|
||||
if (!type) return ''
|
||||
|
||||
if (type in reportTypeMessages) {
|
||||
return formatMessage(reportTypeMessages[type as keyof typeof reportTypeMessages])
|
||||
}
|
||||
|
||||
return capitalizeString(type.replace('-', ' '))
|
||||
}
|
||||
|
||||
export const fileItemTypeMessages = defineMessages({
|
||||
file: {
|
||||
id: 'files.item-type.file',
|
||||
@@ -1094,6 +1148,10 @@ export const commonProjectSettingsMessages = defineMessages({
|
||||
id: 'project.settings.content.title',
|
||||
defaultMessage: 'Content',
|
||||
},
|
||||
disclosures: {
|
||||
id: 'project.settings.disclosures.title',
|
||||
defaultMessage: 'Disclosures',
|
||||
},
|
||||
description: {
|
||||
id: 'project.settings.description.title',
|
||||
defaultMessage: 'Description',
|
||||
@@ -1160,6 +1218,40 @@ export const commonProjectSettingsMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
export const disclosureAiUsageMessages = defineMessages({
|
||||
code: {
|
||||
id: 'project.settings.disclosures.ai.types-code',
|
||||
defaultMessage: 'Code',
|
||||
},
|
||||
assets: {
|
||||
id: 'project.settings.disclosures.ai.types-assets',
|
||||
defaultMessage: 'Assets',
|
||||
},
|
||||
text: {
|
||||
id: 'project.settings.disclosures.ai.types-text',
|
||||
defaultMessage: 'Text',
|
||||
},
|
||||
functionality: {
|
||||
id: 'project.settings.disclosures.ai.types-functionality',
|
||||
defaultMessage: 'Functionality',
|
||||
},
|
||||
})
|
||||
|
||||
export const disclosureTelemetryConsentMessages = defineMessages({
|
||||
opt_in: {
|
||||
id: 'project.settings.disclosures.telemetry.consent-opt-in',
|
||||
defaultMessage: 'Opt-in',
|
||||
},
|
||||
opt_out: {
|
||||
id: 'project.settings.disclosures.telemetry.consent-opt-out',
|
||||
defaultMessage: 'Opt-out',
|
||||
},
|
||||
always_active: {
|
||||
id: 'project.settings.disclosures.telemetry.consent-always-active',
|
||||
defaultMessage: 'Always active',
|
||||
},
|
||||
})
|
||||
|
||||
export const languageSelectorMessages = defineMessages({
|
||||
platformApp: {
|
||||
id: 'settings.language.platform.app',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
export const PROJECT_DISCLOSURE_TYPES = [
|
||||
'ai_content',
|
||||
'advertisements',
|
||||
'epilepsy_triggers',
|
||||
'system_interactions',
|
||||
'telemetry',
|
||||
'derivative_work',
|
||||
'paid_features',
|
||||
'archived',
|
||||
] as const satisfies readonly Labrinth.Projects.v3.ProjectDisclosureType[]
|
||||
|
||||
export const AI_USAGE_TYPES = [
|
||||
'code',
|
||||
'assets',
|
||||
'text',
|
||||
'functionality',
|
||||
] as const satisfies readonly Labrinth.Projects.v3.AiUsage[]
|
||||
|
||||
export const TELEMETRY_CONSENT_TYPES = [
|
||||
'opt_in',
|
||||
'opt_out',
|
||||
'always_active',
|
||||
] as const satisfies readonly Labrinth.Projects.v3.TelemetryConsent[]
|
||||
|
||||
const ALL_CONTENT_PROJECT_TYPES = [
|
||||
'mod',
|
||||
'resourcepack',
|
||||
'datapack',
|
||||
'shader',
|
||||
'modpack',
|
||||
'plugin',
|
||||
'server',
|
||||
] as const
|
||||
|
||||
export const DISCLOSURE_SUPPORTED_PROJECT_TYPES: Record<
|
||||
Labrinth.Projects.v3.ProjectDisclosureType,
|
||||
readonly (typeof ALL_CONTENT_PROJECT_TYPES)[number][]
|
||||
> = {
|
||||
ai_content: ALL_CONTENT_PROJECT_TYPES,
|
||||
advertisements: ALL_CONTENT_PROJECT_TYPES,
|
||||
epilepsy_triggers: ALL_CONTENT_PROJECT_TYPES,
|
||||
derivative_work: ALL_CONTENT_PROJECT_TYPES,
|
||||
paid_features: ALL_CONTENT_PROJECT_TYPES,
|
||||
archived: ALL_CONTENT_PROJECT_TYPES,
|
||||
telemetry: ['mod', 'plugin', 'modpack', 'server'],
|
||||
system_interactions: ['mod', 'plugin', 'modpack'],
|
||||
}
|
||||
|
||||
function normalizeProjectType(type: string): string {
|
||||
return type === 'minecraft_java_server' ? 'server' : type
|
||||
}
|
||||
|
||||
export function isDisclosureCompatibleWithProjectTypes(
|
||||
disclosureType: Labrinth.Projects.v3.ProjectDisclosureType,
|
||||
projectTypes: readonly string[],
|
||||
): boolean {
|
||||
const types = projectTypes.map(normalizeProjectType)
|
||||
return DISCLOSURE_SUPPORTED_PROJECT_TYPES[disclosureType].some((type) => types.includes(type))
|
||||
}
|
||||
|
||||
export function isActiveDisclosure(
|
||||
disclosure: { deleted_at?: string | null } | null | undefined,
|
||||
): boolean {
|
||||
return !!disclosure && disclosure.deleted_at == null
|
||||
}
|
||||
|
||||
export function getActiveDisclosures<T extends { deleted_at?: string | null }>(
|
||||
disclosures: readonly T[] | null | undefined,
|
||||
): T[] {
|
||||
return (disclosures ?? []).filter((disclosure) => isActiveDisclosure(disclosure))
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export { createAttributionGroupTitle } from '../components/external_files/external-project-utils'
|
||||
export * from './advanced-filter-preferences'
|
||||
export * from './auto-icons'
|
||||
export * from './common-messages'
|
||||
export * from './disclosures'
|
||||
export * from './events'
|
||||
export * from './file-extensions'
|
||||
export * from './game-modes'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { cloneDeep, isEqual } from 'es-toolkit'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -15,7 +15,7 @@ export function useSavable<T extends Record<string, unknown>>(
|
||||
save: () => Promise<void>
|
||||
} {
|
||||
const savedValues = computed(data)
|
||||
const currentValues = ref({ ...data() }) as Ref<T>
|
||||
const currentValues = ref(cloneDeep(data())) as Ref<T>
|
||||
const saving = ref(false)
|
||||
|
||||
const changes = computed<Partial<T>>(() => {
|
||||
@@ -32,7 +32,7 @@ export function useSavable<T extends Record<string, unknown>>(
|
||||
const hasChanges = computed(() => Object.keys(changes.value).length > 0)
|
||||
|
||||
const reset = () => {
|
||||
currentValues.value = data()
|
||||
currentValues.value = cloneDeep(data())
|
||||
}
|
||||
|
||||
const saveInternal = async () => {
|
||||
@@ -40,7 +40,7 @@ export function useSavable<T extends Record<string, unknown>>(
|
||||
saving.value = true
|
||||
try {
|
||||
await save(changes.value)
|
||||
currentValues.value = data()
|
||||
currentValues.value = cloneDeep(data())
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
+231
-43
@@ -1,10 +1,35 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ClientIcon, getCategoryIcon, getLoaderIcon, ServerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CircleDollarSignIcon,
|
||||
CircuitBoardIcon,
|
||||
ClientIcon,
|
||||
EyeIcon,
|
||||
getCategoryIcon,
|
||||
getLoaderIcon,
|
||||
GitForkIcon,
|
||||
MegaphoneIcon,
|
||||
RadioTowerIcon,
|
||||
ServerIcon,
|
||||
SparklesIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { sortedCategories } from '@modrinth/utils'
|
||||
import { type Component, computed, readonly, type Ref, ref } from 'vue'
|
||||
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
|
||||
|
||||
import { defineMessage, useVIntl } from '../composables/i18n'
|
||||
import { getProjectTypeIcon } from './auto-icons'
|
||||
import {
|
||||
disclosureAiUsageMessages,
|
||||
disclosureTelemetryConsentMessages,
|
||||
getProjectTypeCategoryMessage,
|
||||
} from './common-messages'
|
||||
import {
|
||||
AI_USAGE_TYPES,
|
||||
isDisclosureCompatibleWithProjectTypes,
|
||||
PROJECT_DISCLOSURE_TYPES,
|
||||
TELEMETRY_CONSENT_TYPES,
|
||||
} from './disclosures'
|
||||
import {
|
||||
DEFAULT_MOD_LOADERS,
|
||||
DEFAULT_PLUGIN_LOADERS,
|
||||
@@ -21,6 +46,7 @@ type BaseOption = {
|
||||
icon?: string | Component
|
||||
query_value?: string
|
||||
group?: string
|
||||
sub_options?: FilterOption[]
|
||||
}
|
||||
|
||||
export type FilterOption = BaseOption &
|
||||
@@ -29,6 +55,40 @@ export type FilterOption = BaseOption &
|
||||
| { method: 'environment'; environment: 'client' | 'server' }
|
||||
)
|
||||
|
||||
export function flattenFilterOptions(options: readonly FilterOption[]): FilterOption[] {
|
||||
return options.flatMap((option) => [
|
||||
option,
|
||||
...(option.sub_options ? flattenFilterOptions(option.sub_options) : []),
|
||||
])
|
||||
}
|
||||
|
||||
export function findFilterOption(
|
||||
options: readonly FilterOption[],
|
||||
optionId: string,
|
||||
): FilterOption | undefined {
|
||||
for (const option of options) {
|
||||
if (option.id === optionId) {
|
||||
return option
|
||||
}
|
||||
if (option.sub_options) {
|
||||
const nested = findFilterOption(option.sub_options, optionId)
|
||||
if (nested) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function findParentFilterOption(
|
||||
options: readonly FilterOption[],
|
||||
optionId: string,
|
||||
): FilterOption | undefined {
|
||||
return options.find((option) =>
|
||||
option.sub_options?.some((subOption) => subOption.id === optionId),
|
||||
)
|
||||
}
|
||||
|
||||
export type FilterMode = 'include' | 'exclude'
|
||||
|
||||
export type FilterType = {
|
||||
@@ -120,6 +180,152 @@ const PROJECT_TYPE_EXCLUSION_FILTERS: Partial<Record<ProjectType, ProjectType[]>
|
||||
datapack: ['mod', 'plugin'],
|
||||
}
|
||||
|
||||
export type DisclosureTypeFilter = Labrinth.Projects.v3.ProjectDisclosureType
|
||||
|
||||
const DISCLOSURE_TYPE_ICONS: Record<DisclosureTypeFilter, Component> = {
|
||||
ai_content: SparklesIcon,
|
||||
advertisements: MegaphoneIcon,
|
||||
epilepsy_triggers: EyeIcon,
|
||||
system_interactions: CircuitBoardIcon,
|
||||
telemetry: RadioTowerIcon,
|
||||
derivative_work: GitForkIcon,
|
||||
paid_features: CircleDollarSignIcon,
|
||||
archived: ArchiveIcon,
|
||||
}
|
||||
|
||||
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 formatAiUsageFilterLabel(
|
||||
formatMessage: FormatMessage,
|
||||
usage: Labrinth.Projects.v3.AiUsage,
|
||||
): string {
|
||||
return formatMessage(
|
||||
defineMessage({
|
||||
id: 'search.filter_type.advanced.disclosure.ai_content.usage',
|
||||
defaultMessage: 'AI {usage}',
|
||||
}),
|
||||
{ usage: formatMessage(disclosureAiUsageMessages[usage]) },
|
||||
)
|
||||
}
|
||||
|
||||
export function formatTelemetryConsentFilterLabel(
|
||||
formatMessage: FormatMessage,
|
||||
consent: Labrinth.Projects.v3.TelemetryConsent,
|
||||
): string {
|
||||
return formatMessage(disclosureTelemetryConsentMessages[consent])
|
||||
}
|
||||
|
||||
function createDisclosureSubOptions(
|
||||
formatMessage: FormatMessage,
|
||||
disclosureType: DisclosureTypeFilter,
|
||||
): FilterOption[] | undefined {
|
||||
switch (disclosureType) {
|
||||
case 'ai_content':
|
||||
return AI_USAGE_TYPES.map((usage) => ({
|
||||
id: `${disclosureType}_${usage}`,
|
||||
formatted_name: formatAiUsageFilterLabel(formatMessage, usage),
|
||||
method: 'or' as const,
|
||||
value: `disclosure_types:${disclosureType}_${usage}`,
|
||||
}))
|
||||
case 'telemetry':
|
||||
return TELEMETRY_CONSENT_TYPES.map((consent) => ({
|
||||
id: `${disclosureType}_${consent}`,
|
||||
formatted_name: formatTelemetryConsentFilterLabel(formatMessage, consent),
|
||||
method: 'or' as const,
|
||||
value: `disclosure_types:${disclosureType}_${consent}`,
|
||||
}))
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function createDisclosureFilterOptions(
|
||||
formatMessage: FormatMessage,
|
||||
projectTypes: readonly ProjectType[],
|
||||
): FilterOption[] {
|
||||
return PROJECT_DISCLOSURE_TYPES.filter((disclosureType) =>
|
||||
isDisclosureCompatibleWithProjectTypes(disclosureType, projectTypes),
|
||||
).map((disclosureType) => ({
|
||||
id: disclosureType,
|
||||
formatted_name: formatDisclosureTypeLabel(formatMessage, disclosureType),
|
||||
icon: DISCLOSURE_TYPE_ICONS[disclosureType],
|
||||
method: 'or' as const,
|
||||
value: `disclosure_types:${disclosureType}`,
|
||||
sub_options: createDisclosureSubOptions(formatMessage, disclosureType),
|
||||
}))
|
||||
}
|
||||
|
||||
export function useSearch(
|
||||
projectTypes: Ref<ProjectType[]>,
|
||||
tags: Ref<Tags>,
|
||||
@@ -151,34 +357,6 @@ export function useSearch(
|
||||
return formatCategory(formatMessage, categoryName)
|
||||
}
|
||||
|
||||
const formatExcludeProjectTypeLabel = (projectType: ProjectType): string => {
|
||||
switch (projectType) {
|
||||
case 'mod':
|
||||
return formatMessage(
|
||||
defineMessage({
|
||||
id: 'search.filter_type.advanced.exclude_mod',
|
||||
defaultMessage: 'Exclude mods',
|
||||
}),
|
||||
)
|
||||
case 'plugin':
|
||||
return formatMessage(
|
||||
defineMessage({
|
||||
id: 'search.filter_type.advanced.exclude_plugin',
|
||||
defaultMessage: 'Exclude plugins',
|
||||
}),
|
||||
)
|
||||
case 'datapack':
|
||||
return formatMessage(
|
||||
defineMessage({
|
||||
id: 'search.filter_type.advanced.exclude_datapack',
|
||||
defaultMessage: 'Exclude data packs',
|
||||
}),
|
||||
)
|
||||
default:
|
||||
return projectType
|
||||
}
|
||||
}
|
||||
|
||||
const filters = computed(() => {
|
||||
const categoryFilters: Record<string, FilterType> = {}
|
||||
for (const category of sortedCategories(tags.value, formatCategoryName, locale.value)) {
|
||||
@@ -479,21 +657,25 @@ export function useSearch(
|
||||
formatted_name: formatMessage(
|
||||
defineMessage({
|
||||
id: 'search.filter_type.advanced',
|
||||
defaultMessage: 'Advanced',
|
||||
defaultMessage: 'Advanced exclusions',
|
||||
}),
|
||||
),
|
||||
supported_project_types: ['mod', 'plugin', 'datapack'],
|
||||
supported_project_types: ALL_PROJECT_TYPES,
|
||||
display: 'all',
|
||||
query_param: 'a',
|
||||
supports: ['exclude'],
|
||||
searchable: false,
|
||||
ordering: -1000,
|
||||
options: excludeableProjectTypes.map((target) => ({
|
||||
id: target,
|
||||
formatted_name: formatExcludeProjectTypeLabel(target),
|
||||
method: 'and',
|
||||
value: `all_project_types:${mapProjectTypeToSearch(target)}`,
|
||||
})),
|
||||
options: [
|
||||
...createDisclosureFilterOptions(formatMessage, projectTypes.value),
|
||||
...excludeableProjectTypes.map((target) => ({
|
||||
id: target,
|
||||
formatted_name: formatMessage(getProjectTypeCategoryMessage(target)),
|
||||
icon: getProjectTypeIcon(target),
|
||||
method: 'and' as const,
|
||||
value: `all_project_types:${mapProjectTypeToSearch(target)}`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -503,6 +685,7 @@ export function useSearch(
|
||||
projectTypes.value.includes(projectType),
|
||||
),
|
||||
)
|
||||
.filter((filterType) => filterType.id !== 'advanced' || filterType.options.length > 0)
|
||||
.sort((a, b) => (b.ordering ?? 0) - (a.ordering ?? 0))
|
||||
})
|
||||
|
||||
@@ -526,10 +709,10 @@ export function useSearch(
|
||||
console.error(`Filter type ${filterValue.type} not found`)
|
||||
continue
|
||||
}
|
||||
if (type.id === 'advanced') {
|
||||
if (type.id === 'advanced' && isProjectTypeExclusionOption(filterValue.option)) {
|
||||
continue
|
||||
}
|
||||
let option = type?.options.find((option) => option.id === filterValue.option)
|
||||
let option = type ? findFilterOption(type.options, filterValue.option) : undefined
|
||||
if (!option && type.allows_custom_options) {
|
||||
option = {
|
||||
id: filterValue.option,
|
||||
@@ -620,7 +803,10 @@ export function useSearch(
|
||||
}
|
||||
|
||||
const excludedProjectTypes = filterValues
|
||||
.filter((filterValue) => filterValue.type === 'advanced')
|
||||
.filter(
|
||||
(filterValue) =>
|
||||
filterValue.type === 'advanced' && isProjectTypeExclusionOption(filterValue.option),
|
||||
)
|
||||
.map((filterValue) =>
|
||||
formatSearchFilterValue(mapProjectTypeToSearch(filterValue.option as ProjectType)),
|
||||
)
|
||||
@@ -675,7 +861,7 @@ export function useSearch(
|
||||
const set = typeof filter === 'string' ? new Set([filter]) : new Set(filter)
|
||||
|
||||
typesLoop: for (const type of filters.value) {
|
||||
for (const option of type.options) {
|
||||
for (const option of flattenFilterOptions(type.options)) {
|
||||
const value = getOptionValue(option, false)
|
||||
if (
|
||||
set.has(value) &&
|
||||
@@ -734,7 +920,9 @@ export function useSearch(
|
||||
let matched = false
|
||||
|
||||
for (const type of types) {
|
||||
const option = type.options.find((option) => getOptionValue(option, negative) === value)
|
||||
const option = flattenFilterOptions(type.options).find(
|
||||
(option) => getOptionValue(option, negative) === value,
|
||||
)
|
||||
if (!option) {
|
||||
continue
|
||||
}
|
||||
@@ -773,7 +961,7 @@ export function useSearch(
|
||||
|
||||
currentFilters.value.forEach((filterValue) => {
|
||||
const type = filters.value.find((type) => type.id === filterValue.type)
|
||||
const option = type?.options.find((option) => option.id === filterValue.option)
|
||||
const option = type ? findFilterOption(type.options, filterValue.option) : undefined
|
||||
if (type && option) {
|
||||
const value = getOptionValue(option, filterValue.negative)
|
||||
if (items[type.query_param]) {
|
||||
|
||||
@@ -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, findFilterOption, 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 = findFilterOption(filterType.options, 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') {
|
||||
@@ -442,7 +475,7 @@ export function useServerSearch(opts: {
|
||||
for (const value of values) {
|
||||
const isNegative = value.startsWith('!')
|
||||
const cleanValue = isNegative ? value.slice(1) : value
|
||||
const option = filterType.options.find((o) => o.id === cleanValue)
|
||||
const option = findFilterOption(filterType.options, cleanValue)
|
||||
if (option) {
|
||||
serverCurrentFilters.value.push({
|
||||
type: filterType.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTextVNode, isVNode, toDisplayString, type VNode } from 'vue'
|
||||
import { createTextVNode, createVNode, Fragment, isVNode, toDisplayString, type VNode } from 'vue'
|
||||
|
||||
/**
|
||||
* Checks whether a specific child is a VNode. If not, converts it to a display
|
||||
@@ -18,8 +18,20 @@ function normalizeChild(child: unknown): VNode {
|
||||
* that string.
|
||||
*
|
||||
* @param children Children to normalize.
|
||||
* @returns Children with all of non-VNodes converted to display strings.
|
||||
* @returns A single VNode (or Fragment VNode when there are multiple children).
|
||||
*/
|
||||
export function normalizeChildren(children: unknown | unknown[]): VNode[] {
|
||||
return Array.isArray(children) ? children.map(normalizeChild) : [normalizeChild(children)]
|
||||
export function normalizeChildren(children: unknown | unknown[]): VNode {
|
||||
const normalized = Array.isArray(children)
|
||||
? children.map(normalizeChild)
|
||||
: [normalizeChild(children)]
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return createTextVNode('')
|
||||
}
|
||||
|
||||
if (normalized.length === 1) {
|
||||
return normalized[0]!
|
||||
}
|
||||
|
||||
return createVNode(Fragment, null, normalized)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user