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',
|
||||
|
||||
Reference in New Issue
Block a user