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:
Prospector
2026-08-13 09:01:18 -07:00
committed by GitHub
co-authored by ThatGravyBoat chyzman Truman Gao sychic coolbot
parent 06b7c44dcc
commit 755825b09a
230 changed files with 5802 additions and 1268 deletions
+281 -146
View File
@@ -5,27 +5,48 @@
</Teleport>
<template v-if="isSettings">
<div v-if="canAccessSettings" class="normal-page no-sidebar" :class="`align-${marginTarget}`">
<div class="normal-page__header">
<div
class="mb-4 flex flex-wrap items-center gap-x-2 gap-y-3 border-0 border-b-[1px] border-solid border-divider pb-4 text-lg font-semibold"
>
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}`"
class="flex items-center gap-2 hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar :src="project.icon_url" size="32px" />
{{ project.title }}
</nuxt-link>
<ChevronRightIcon />
<span class="flex grow font-extrabold text-contrast">{{
formatMessage(messages.settingsTitle)
}}</span>
<div class="flex gap-2">
<ButtonLink to="/dashboard/projects"
><ListIcon /> {{ formatMessage(messages.visitProjectsDashboard) }}
<div class="normal-page__header mb-6">
<PageHeader :title="project.title" :row-class="'items-center'">
<template #leading>
<ButtonLink
v-if="settingsBackDestination"
v-tooltip="settingsBackDestination.label"
:to="settingsBackDestination.to"
size="lg"
class="!w-10 !rounded-full !px-0"
:aria-label="settingsBackDestination.label"
>
<LeftArrowIcon />
</ButtonLink>
</div>
</div>
<Avatar :src="project.icon_url" :tint-by="project.id" size="64px" />
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem>
{{
formatMessage(messages.editingProject, {
projectType: projectTypeDisplay.toLowerCase(),
})
}}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem>
{{
formatMessage(commonMessages.projectCreated, {
date: formatRelativeTime(project.published),
})
}}
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonLink :to="`${projectPath}`">
<CompassIcon />
{{ formatMessage(messages.projectPage) }}
</ButtonLink>
</PageHeaderActions>
</template>
</PageHeader>
<ProjectMemberHeader
v-if="currentMember && false"
:project="project"
@@ -69,7 +90,7 @@
</div>
<ProjectDownloadModal
ref="downloadModal"
:project-id="routeProjectId"
:project-id="projectId"
:download-reason="downloadReason"
@download="triggerDownloadAnimation"
/>
@@ -118,7 +139,7 @@
v-if="
projectV3 &&
currentMember &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
(projectV3.status === 'draft' || tags.rejectedStatuses.includes(projectV3.status))
"
:project="project"
:project-v3="projectV3"
@@ -135,7 +156,7 @@
v-if="projectV3Loaded"
:project="project"
:project-v3="projectV3"
:show-status-badge="!!currentMember || project.status !== 'approved'"
:show-status-badge="!!currentMember || projectV3.status !== 'approved'"
@category="(category) => router.push(`${projectSearchUrl}?f=categories:${category}`)"
>
<template #actions>
@@ -425,9 +446,12 @@
<SettingsIcon /> {{ formatMessage(messages.reviewEnvironmentSettings) }}
</Button>
</Admonition>
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
{{ formatMessage(messages.archivedMessage, { title: project.title }) }}
</MessageBanner>
<ArchivedProjectBanner
v-if="isArchived"
:title="project.title"
:reason="archivedDisclosure?.note"
class="mt-4"
/>
</div>
<div class="normal-page__sidebar">
@@ -452,7 +476,7 @@
:project-v3="projectV3"
class="card flex-card"
/>
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(projectV3.status)" />
<ProjectSidebarLinks
:project="project"
:project-v3="projectV3"
@@ -502,12 +526,12 @@
<script setup>
import {
ChartIcon,
ChevronRightIcon,
ClipboardCopyIcon,
CompassIcon,
DownloadIcon,
FolderSearchIcon,
HeartIcon,
ListIcon,
LeftArrowIcon,
MoreVerticalIcon,
PlayIcon,
ReportIcon,
@@ -520,18 +544,24 @@ import {
import { getMarginTarget, moderationSettings } from '@modrinth/moderation'
import {
Admonition,
ArchivedProjectBanner,
Avatar,
BrowseInstallHeader,
Button,
ButtonLink,
commonMessages,
defineMessages,
getActiveDisclosures,
IconButton,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NavTabs,
OpenInAppModal,
PageHeader,
PageHeaderActions,
PageHeaderMetadata,
PageHeaderMetadataItem,
PROJECT_DEP_MARKER_QUERY,
ProjectBackgroundGradient,
ProjectEnvironmentModal,
@@ -547,6 +577,7 @@ import {
TeleportOverflowMenu,
useDebugLogger,
useFormatPrice,
useRelativeTime,
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
@@ -559,7 +590,6 @@ import { onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } fro
import { navigateTo } from '#app'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
@@ -568,7 +598,7 @@ import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { STALE_TIME, STALE_TIME_LONG, warmProjectCheckCaches } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { useServerInstallContent } from '~/composables/use-server-install-content'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
@@ -596,8 +626,11 @@ const { addNotification } = notifications
const auth = await useAuth()
const user = await useUser()
// Route param for initial lookup (middleware caches by both slug and ID)
const routeProjectId = ref(useRouteId('project'))
// Route slug or ID — resolve to canonical ID before fetching project data
const routeParam = computed(() => {
const param = route.params.project
return Array.isArray(param) ? param[0] : param
})
const { createProjectDownloadUrl } = useCdnDownloadContext()
@@ -619,6 +652,7 @@ watch(() => route.query.dep, absorbDepQuery, { immediate: true })
const tags = useGeneratedState()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const formatRelativeTime = useRelativeTime()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
@@ -658,15 +692,18 @@ function handlePlayServerProject() {
}
const messages = defineMessages({
archivedMessage: {
id: 'project.status.archived.message',
defaultMessage:
'{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project.',
backToAllProjects: {
id: 'project.settings.back-to-all-projects',
defaultMessage: 'Back to all projects',
},
backToDiscover: {
id: 'project.install-context.back-to-discover',
defaultMessage: 'Back to discover',
},
backToProjectPage: {
id: 'project.settings.back-to-project-page',
defaultMessage: 'Back to project page',
},
changelogTab: {
id: 'project.navigation.changelog',
defaultMessage: 'Changelog',
@@ -761,6 +798,18 @@ const messages = defineMessages({
id: 'project.environment.migration.review-button',
defaultMessage: 'Review environment settings',
},
projectPage: {
id: 'project.actions.project-page',
defaultMessage: 'Project page',
},
backToProjectPage: {
id: 'project.actions.back-to-project-page',
defaultMessage: 'Back to project page',
},
backToAllProjects: {
id: 'project.actions.back-to-all-projects',
defaultMessage: 'Back to all projects',
},
reviewProject: {
id: 'project.actions.review-project',
defaultMessage: 'Review project',
@@ -781,17 +830,13 @@ const messages = defineMessages({
id: 'project.actions.servers-promo.title',
defaultMessage: 'Create a server',
},
settingsTitle: {
id: 'project.settings.title',
defaultMessage: 'Settings',
},
versionsTab: {
id: 'project.versions.title',
defaultMessage: 'Versions',
},
visitProjectsDashboard: {
id: 'project.settings.visit-dashboard',
defaultMessage: 'Visit projects dashboard',
editingProject: {
id: 'project.settings.editing-project',
defaultMessage: 'Editing {projectType} project',
},
})
@@ -802,7 +847,7 @@ const collections = computed(() =>
)
if (
!routeProjectId.value ||
!routeParam.value ||
!(
tags.value.projectTypes.find((x) => x.id === route.params.type) ||
route.params.type === 'project'
@@ -819,11 +864,42 @@ if (
const client = injectModrinthClient()
const queryClient = useQueryClient()
// V2 Project - hits middleware cache (uses route param for lookup)
const { data: projectRaw, error: projectV2Error } = useQuery({
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value),
// Resolve route slug/ID to the canonical project ID (middleware warms this cache)
const { data: projectCheck, error: projectCheckError } = useQuery({
queryKey: computed(() => ['project', 'check', routeParam.value]),
queryFn: () => client.labrinth.projects_v2.check(routeParam.value),
staleTime: STALE_TIME,
enabled: computed(() => !!routeParam.value),
})
const projectId = computed(() => projectCheck.value?.id)
watch(
projectCheckError,
(error) => {
if (error) {
const status = error.statusCode ?? error.status ?? 500
showError({
fatal: true,
statusCode: status,
message:
status === 404
? formatMessage(messages.projectNotFound)
: formatMessage(messages.errorLoadingProject, {
message: error.message ? `: ${error.message}` : '',
}),
})
}
},
{ immediate: true },
)
// V2 Project — keyed by canonical ID
const { data: projectRaw, error: projectV2Error } = useQuery({
queryKey: computed(() => ['project', 'v2', projectId.value]),
queryFn: () => client.labrinth.projects_v2.get(projectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!projectId.value),
})
// Handle project not found - use showError since watch runs outside Nuxt context
@@ -907,9 +983,6 @@ const projectHeaderInstallContext = computed(() => {
}
})
// Use actual project ID for dependent queries (ensures cache consistency)
const projectId = computed(() => projectRaw.value?.id)
const sharedProjectId = injectCurrentProjectId(null)
if (sharedProjectId) {
watchEffect(() => {
@@ -926,9 +999,10 @@ const {
error: _projectV3Error,
isPending: projectV3Pending,
} = useQuery({
queryKey: computed(() => ['project', 'v3', routeProjectId.value]),
queryFn: () => client.labrinth.projects_v3.get(routeProjectId.value),
queryKey: computed(() => ['project', 'v3', projectId.value]),
queryFn: () => client.labrinth.projects_v3.get(projectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!projectId.value),
})
// Server sidebar: modpack version + project for required content
@@ -1091,6 +1165,21 @@ const { data: organizationRaw, isPending: organizationPending } = useQuery({
// Return null when the project no longer belongs to an organization.
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
const DISCLOSURE_STALE_TIME = 1000 * 60 * 5
const { data: disclosuresResponse } = useQuery({
queryKey: computed(() => ['project', 'disclosures', 'v3', projectId.value]),
queryFn: () => client.labrinth.projects_v3.getDisclosures(projectId.value),
staleTime: DISCLOSURE_STALE_TIME,
enabled: computed(() => !!projectId.value),
})
const archivedDisclosure = computed(() =>
getActiveDisclosures(disclosuresResponse.value?.disclosures).find(
(disclosure) => disclosure.type === 'archived',
),
)
const isArchived = computed(() => !!archivedDisclosure.value)
const creatorsLoading = computed(
() =>
!projectRaw.value ||
@@ -1153,62 +1242,93 @@ function loadDependencies() {
const hasVersions = computed(() => (project.value?.versions?.length ?? 0) > 0)
async function invalidateProject() {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
if (routeProjectId.value !== projectId.value) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId.value] })
const id = projectId.value
if (!id) {
return
}
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', id] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', id] })
// Prefix match — invalidates members, versions, dependencies, organization
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', id] })
}
async function redirectIfNewSlug(newSlug, id) {
if (newSlug === undefined || newSlug === route.params.project) {
return
}
warmProjectCheckCaches(queryClient, { id, slug: newSlug })
await navigateTo(
{
name: route.name,
params: {
type: route.params.type,
project: newSlug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
function mergeV3ProjectPatch(old, data) {
if (!old) {
return old
}
const merged = { ...old }
for (const [key, value] of Object.entries(data)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
merged[key] &&
typeof merged[key] === 'object' &&
!Array.isArray(merged[key])
) {
merged[key] = { ...merged[key], ...value }
} else {
merged[key] = value
}
}
return merged
}
// Mutation for patching project data
const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v2.edit(projectId, data)
if (data.slug !== undefined && data.slug !== route.params.project) {
routeProjectId.value = data.slug
await navigateTo(
{
name: route.name,
params: {
type: route.params.type,
project: data.slug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
await redirectIfNewSlug(data.slug, projectId)
return data
},
onMutate: async ({ projectId, data }) => {
// Cancel outgoing refetches for both slug-based and ID-based cache keys
// The query may be keyed by slug (routeProjectId.value) but we also have the actual UUID (projectId)
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
}
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
// Snapshot previous value from the active query (uses route param as key)
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
const previousV2 = queryClient.getQueryData(['project', 'v2', projectId])
const previousV3 = queryClient.getQueryData(['project', 'v3', projectId])
// Optimistic update on the active query key
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
if (!old) return old
return { ...old, ...data }
})
if (data.slug !== undefined) {
queryClient.setQueryData(['project', 'v3', projectId], (old) =>
old ? { ...old, slug: data.slug } : old,
)
}
return { previousProject }
return { previousV2, previousV3, projectId }
},
onError: (err, _variables, context) => {
// Rollback on error using the active query key
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
if (context?.previousV2) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2)
}
if (context?.previousV3) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1229,28 +1349,21 @@ const patchStatusMutation = useMutation({
},
onMutate: async ({ projectId, status }) => {
// Cancel outgoing refetches for both slug-based and ID-based cache keys
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
}
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
// Snapshot previous value from the active query (uses route param as key)
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
// Optimistic update on the active query key
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
if (!old) return old
return { ...old, status }
})
return { previousProject }
return { previousProject, projectId }
},
onError: (err, _variables, context) => {
// Rollback on error using the active query key
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1268,40 +1381,33 @@ const patchStatusMutation = useMutation({
const patchProjectV3Mutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v3.edit(projectId, data)
await redirectIfNewSlug(data.slug, projectId)
return data
},
onMutate: async ({ projectId, data }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v3', projectId])
const previousV3 = queryClient.getQueryData(['project', 'v3', projectId])
const previousV2 = queryClient.getQueryData(['project', 'v2', projectId])
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
if (!old) return old
const merged = { ...old }
for (const [key, value] of Object.entries(data)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
merged[key] &&
typeof merged[key] === 'object' &&
!Array.isArray(merged[key])
) {
merged[key] = { ...merged[key], ...value }
} else {
merged[key] = value
}
}
return merged
})
queryClient.setQueryData(['project', 'v3', projectId], (old) => mergeV3ProjectPatch(old, data))
if (data.slug !== undefined) {
queryClient.setQueryData(['project', 'v2', projectId], (old) =>
old ? { ...old, slug: data.slug } : old,
)
}
return { previousProject, projectId }
return { previousV3, previousV2, projectId }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousProject)
if (context?.previousV3) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3)
}
if (context?.previousV2) {
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1355,12 +1461,12 @@ const createGalleryItemMutation = useMutation({
})
},
onMutate: async ({ title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
onMutate: async ({ projectId, title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
if (!old) return old
const newItem = {
url: '',
@@ -1377,12 +1483,12 @@ const createGalleryItemMutation = useMutation({
}
})
return { previousProject }
return { previousProject, projectId }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1406,12 +1512,12 @@ const editGalleryItemMutation = useMutation({
})
},
onMutate: async ({ imageUrl, title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
onMutate: async ({ projectId, imageUrl, title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
if (!old) return old
return {
...old,
@@ -1430,12 +1536,12 @@ const editGalleryItemMutation = useMutation({
}
})
return { previousProject }
return { previousProject, projectId }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1454,12 +1560,12 @@ const deleteGalleryItemMutation = useMutation({
await client.labrinth.projects_v2.deleteGalleryImage(projectId, imageUrl)
},
onMutate: async ({ imageUrl }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
onMutate: async ({ projectId, imageUrl }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
if (!old) return old
return {
...old,
@@ -1467,12 +1573,12 @@ const deleteGalleryItemMutation = useMutation({
}
})
return { previousProject }
return { previousProject, projectId }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1602,6 +1708,38 @@ const projectPath = computed(() =>
? `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
: '',
)
const settingsEntryRouteName = ref()
function setSettingsEntryRoute() {
const backPath = window.history.state?.back
if (!isSettings.value || typeof backPath !== 'string') {
settingsEntryRouteName.value = undefined
return
}
settingsEntryRouteName.value = router.resolve(backPath).name?.toString()
}
onMounted(setSettingsEntryRoute)
watch(isSettings, setSettingsEntryRoute)
const settingsBackDestination = computed(() => {
switch (settingsEntryRouteName.value) {
case 'dashboard-projects':
return {
label: formatMessage(messages.backToAllProjects),
to: '/dashboard/projects',
}
case 'type-project':
return {
label: formatMessage(messages.backToProjectPage),
to: projectPath.value,
}
default:
return undefined
}
})
const projectHeaderPrimaryColor = computed(() =>
currentMember.value || route.name === 'type-project-version-version' ? 'standard' : 'brand',
)
@@ -1693,10 +1831,7 @@ if (!route.name.startsWith('type-project-settings')) {
ogDescription: () => project.value?.description ?? '',
ogImage: () => project.value?.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogUrl: createCanonicalUrl,
robots: () =>
project.value?.status === 'approved' || project.value?.status === 'archived'
? 'all'
: 'noindex',
robots: () => (project.value?.status === 'approved' ? 'all' : 'noindex'),
})
} else {
useSeoMeta({
@@ -1,5 +1,6 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<Modal
v-if="currentMember"
ref="modal_edit_item"
@@ -18,12 +19,7 @@
:accept="acceptFileTypes"
:max-size="5242880"
aria-label="Replace image"
@change="
(x) => {
editFile = x[0]
showPreviewImage()
}
"
@change="replaceEditFile"
>
<TransferIcon aria-hidden="true" />
</FileButton>
@@ -315,6 +311,8 @@ import {
} from '@modrinth/ui'
import { useEventListener } from '@vueuse/core'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
const formatDate = useFormatDateTime({
@@ -333,6 +331,7 @@ const {
} = injectProjectPageContext()
// Template refs
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
const modalEditItem = useTemplateRef('modal_edit_item')
const modalConfirm = useTemplateRef('modal_confirm')
@@ -439,14 +438,35 @@ function resetEdit() {
previewImage.value = null
}
function handleFiles(files: File[]) {
async function handleFiles(files: File[]) {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
resetEdit()
editFile.value = files[0]
editFile.value = file
showPreviewImage()
modalEditItem.value?.show()
}
async function replaceEditFile(files: File[]) {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
editFile.value = file
showPreviewImage()
}
function showPreviewImage() {
const reader = new FileReader()
if (editFile.value instanceof Blob) {
@@ -6,6 +6,7 @@ import {
GlobeIcon,
ImageIcon,
InfoIcon,
IssuesIcon,
LinkIcon,
ServerIcon,
SignatureIcon,
@@ -71,6 +72,11 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.server),
icon: ServerIcon,
},
{
link: `/${base}/settings/disclosures`,
label: formatMessage(commonProjectSettingsMessages.disclosures),
icon: IssuesIcon,
},
{
link: `/${base}/settings/tags`,
label: formatMessage(commonProjectSettingsMessages.tags),
@@ -173,7 +179,7 @@ const moderatorSeeUserUi = computed<boolean>({
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
<div class="grid gap-4 lg:grid-cols-[1fr_3fr]">
<div class="grid gap-6 lg:grid-cols-[1fr_3fr]">
<div>
<NavStack :items="navItems" />
<div v-if="isStaff(currentMember?.user)" class="mt-4 flex items-center gap-2">
@@ -3,5 +3,9 @@
</template>
<script setup lang="ts">
import { commonProjectSettingsMessages } from '@modrinth/ui'
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
useProjectSettingsHeadTitle(commonProjectSettingsMessages.analytics)
</script>
@@ -1,5 +1,6 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<ConfirmLeaveModal ref="confirmLeaveModal" />
<div class="universal-card">
<div class="markdown-disclaimer">
@@ -23,9 +24,10 @@
"
:on-image-upload="onUploadHandler"
/>
<div v-if="descriptionWarning" class="flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ descriptionWarning }}
<div v-if="descriptionWarning" class="mt-2">
<SettingsInlineWarning>
{{ descriptionWarning }}
</SettingsInlineWarning>
</div>
</div>
<UnsavedChangesPopup
@@ -39,22 +41,28 @@
</template>
<script lang="ts" setup>
import { TriangleAlertIcon } from '@modrinth/assets'
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import {
commonProjectSettingsMessages,
ConfirmLeaveModal,
injectProjectPageContext,
MarkdownEditor,
SettingsInlineWarning,
UnsavedChangesPopup,
usePageLeaveSafety,
useSavable,
} from '@modrinth/ui'
import { TeamMemberPermission } from '@modrinth/utils'
import { computed } from 'vue'
import { computed, useTemplateRef } from 'vue'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { fileDeclaresAi } from '~/helpers/c2pa'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.description)
const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => ({ description: project.value.body }),
@@ -77,6 +85,10 @@ const descriptionWarning = computed(() => {
})
async function onUploadHandler(file: File) {
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
const response = await useImageUpload(file, {
context: 'project',
projectID: project.value.id,
@@ -0,0 +1,365 @@
<script setup lang="ts">
import {
commonMessages,
ConfirmLeaveModal,
defineMessages,
EmptyState,
injectModrinthClient,
injectProjectPageContext,
IntlFormatted,
isDisclosureCompatibleWithProjectTypes,
type MessageDescriptor,
normalizeChildren,
UnsavedChangesPopup,
usePageLeaveSafety,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { isStaff, TeamMemberPermission } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import {
AdvertisingDisclosureCard,
AiDisclosureCard,
ArchivedDisclosureCard,
DerivativeDisclosureCard,
type DisclosureFormIssue,
type DisclosureLockStatus,
disclosuresToForm,
type DisclosureType,
type DisclosureUpdatedByUser,
findDisclosureData,
getDisclosureFormIssues,
getDisclosureFormSnapshot,
PaidFeaturesDisclosureCard,
PhotosensitivityDisclosureCard,
SystemInteractionsDisclosureCard,
TelemetryDisclosureCard,
toModifyRequests,
} from '~/components/ui/project-settings/disclosures'
import { useAuth } from '~/composables/auth'
const DISCLOSURE_QUERY_STALE_TIME = 1000 * 60 * 5
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const { projectV2: project, projectV3, currentMember } = injectProjectPageContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
const auth = await useAuth()
const projectTypes = computed(() => projectV3.value?.project_types ?? [project.value.project_type])
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const canEditDisclosures = computed(
() => project.value.versions.length > 0 || isServerProject.value,
)
function isDisclosureVisible(type: DisclosureType) {
return isDisclosureCompatibleWithProjectTypes(type, projectTypes.value)
}
const isActingAsModerator = computed(
() => isStaff(currentMember.value?.user) && !flags.value.showModeratorProjectMemberUi,
)
const messages = defineMessages({
title: {
id: 'project.settings.disclosures.content-disclosures',
defaultMessage: 'Content disclosures',
},
description: {
id: 'project.settings.disclosures.description',
defaultMessage: `You must add any applicable content disclosures to your project in compliance with <rules>Modrinth's Content Rules</rules>.`,
},
noPermission: {
id: 'project.settings.disclosures.save-blocked.no-permission',
defaultMessage: `You don't have permission to edit this project's disclosures.`,
},
uploadVersionFirstHeading: {
id: 'project.settings.disclosures.upload-version-first.heading',
defaultMessage: 'Upload versions before adding disclosures',
},
})
const issueMessages = defineMessages({
'advertising-note': {
id: 'project.settings.disclosures.save-blocked.advertising-note',
defaultMessage: 'Advertising disclosure requires explanation.',
},
'paid-features-empty': {
id: 'project.settings.disclosures.save-blocked.paid-features-empty',
defaultMessage: 'Paid features disclosure must list at least one paid feature.',
},
'telemetry-empty': {
id: 'project.settings.disclosures.save-blocked.telemetry-empty',
defaultMessage: 'Telemetry disclosure must list at least one type of data collected.',
},
'derivative-empty': {
id: 'project.settings.disclosures.save-blocked.derivative-empty',
defaultMessage: 'Derivative works disclosure must list at least one source work.',
},
'derivative-source-label': {
id: 'project.settings.disclosures.save-blocked.derivative-source-label',
defaultMessage: 'Derivative work sources must have a name.',
},
'photosensitivity-note': {
id: 'project.settings.disclosures.save-blocked.photosensitivity-note',
defaultMessage: 'Photosensitivity warning disclosure must include a description.',
},
'system-interactions-note': {
id: 'project.settings.disclosures.save-blocked.system-interactions-note',
defaultMessage: 'External system interactions disclosure must include a description.',
},
}) satisfies Record<DisclosureFormIssue, MessageDescriptor>
useProjectSettingsHeadTitle(messages.title)
const disclosuresQueryKey = computed(
() => ['project', 'disclosures', 'v3', project.value.id] as const,
)
const { data: disclosuresResponse } = useQuery({
queryKey: disclosuresQueryKey,
queryFn: () => labrinth.projects_v3.getDisclosures(project.value.id),
staleTime: DISCLOSURE_QUERY_STALE_TIME,
})
const updaterUserIds = computed(() => {
const ids = new Set<string>()
for (const disclosure of disclosuresResponse.value?.disclosures ?? []) {
if (disclosure.updated_by) {
ids.add(disclosure.updated_by)
}
}
return [...ids]
})
const { data: updaterUsers } = useQuery({
queryKey: computed(() => ['users', 'disclosures', updaterUserIds.value] as const),
queryFn: () => labrinth.users_v2.getMultiple(updaterUserIds.value),
enabled: computed(() => updaterUserIds.value.length > 0),
staleTime: DISCLOSURE_QUERY_STALE_TIME,
})
const updaterUsersById = computed(() => {
const map = new Map<string, NonNullable<typeof updaterUsers.value>[number]>()
for (const user of updaterUsers.value ?? []) {
map.set(user.id, user)
}
return map
})
const currentUser = computed((): DisclosureUpdatedByUser | null => {
const user = auth.value?.user
if (!user?.id || !user.username) {
return null
}
return {
id: user.id,
username: user.username,
avatar_url: user.avatar_url,
}
})
function resolveUpdatedBy(userId: string | null | undefined): DisclosureUpdatedByUser | null {
if (!userId) return null
return (
updaterUsersById.value.get(userId) ??
(currentUser.value?.id === userId ? currentUser.value : null)
)
}
const hasPermission = computed(
() => !!((currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS),
)
const {
saved,
current,
saving,
reset,
save: saveForm,
} = useSavable(
() => disclosuresToForm(disclosuresResponse.value?.disclosures ?? []),
async () => {
if (!hasPermission.value || !canSave.value) {
throw new Error('Disclosures form is not valid')
}
const previous = disclosuresToForm(disclosuresResponse.value?.disclosures ?? [])
const requests = toModifyRequests(current.value, previous)
for (const request of requests) {
await labrinth.projects_v3.modifyDisclosures(project.value.id, request)
}
await queryClient.invalidateQueries({ queryKey: disclosuresQueryKey.value })
},
)
const savedSnapshot = computed(() => getDisclosureFormSnapshot(saved.value))
const currentSnapshot = computed(() => getDisclosureFormSnapshot(current.value))
const hasChanges = computed(
() => JSON.stringify(savedSnapshot.value) !== JSON.stringify(currentSnapshot.value),
)
async function save() {
if (!hasChanges.value) return
await saveForm()
}
function disclosureUpdateProps(type: DisclosureType) {
const disclosure = findDisclosureData(disclosuresResponse.value?.disclosures, type)
const savedLockStatus = disclosure?.lock_status ?? 'unlocked'
const lockStatus = current.value.lockStatuses[type] ?? savedLockStatus
const lockedForAuthor = !isActingAsModerator.value
return {
disabled:
saving.value ||
!hasPermission.value ||
(lockedForAuthor && savedLockStatus === 'fully_locked'),
toggleDisabled: saving.value || (lockedForAuthor && savedLockStatus !== 'unlocked'),
updatedAt: disclosure?.updated_at,
updatedBy: resolveUpdatedBy(disclosure?.updated_by),
setByModerator: !!disclosure?.set_by_moderator,
lockStatus,
showLockControls: isActingAsModerator.value,
}
}
function setDisclosureLockStatus(type: DisclosureType, lockStatus: DisclosureLockStatus) {
current.value.lockStatuses = {
...current.value.lockStatuses,
[type]: lockStatus,
}
}
watch(
() => disclosuresResponse.value,
(value, previous) => {
if (value && (!previous || !hasChanges.value)) {
reset()
}
},
)
const issues = computed(() => getDisclosureFormIssues(current.value, projectTypes.value))
const canSave = computed(() => hasPermission.value && issues.value.length === 0)
const saveDisabledReason = computed(() => {
if (!hasPermission.value) {
// should never come up but y'never know
return formatMessage(messages.noPermission)
}
return issues.value.map((issue) => formatMessage(issueMessages[issue]))
})
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
</script>
<template>
<div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<h2 class="m-0 text-2xl font-semibold">
{{ formatMessage(messages.title) }}
</h2>
<p class="mb-4 mt-2">
<IntlFormatted :message-id="messages.description">
<template #rules="{ children }">
<nuxt-link to="/legal/rules" target="_blank" class="underline hover:text-contrast">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</p>
<EmptyState
v-if="!canEditDisclosures"
type="no-documents"
:heading="formatMessage(messages.uploadVersionFirstHeading)"
:description="formatMessage(commonMessages.uploadVersionsEmptyStateDescription)"
/>
<template v-else>
<div class="flex flex-col gap-4">
<AiDisclosureCard
v-if="isDisclosureVisible('ai_content')"
v-model="current.ai"
v-bind="disclosureUpdateProps('ai_content')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('ai_content', status)
"
/>
<AdvertisingDisclosureCard
v-if="isDisclosureVisible('advertisements')"
v-model="current.advertising"
v-bind="disclosureUpdateProps('advertisements')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('advertisements', status)
"
/>
<PaidFeaturesDisclosureCard
v-if="isDisclosureVisible('paid_features')"
v-model="current.paidFeatures"
v-bind="disclosureUpdateProps('paid_features')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('paid_features', status)
"
/>
<TelemetryDisclosureCard
v-if="isDisclosureVisible('telemetry')"
v-model="current.telemetry"
v-bind="disclosureUpdateProps('telemetry')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('telemetry', status)
"
/>
<DerivativeDisclosureCard
v-if="isDisclosureVisible('derivative_work')"
v-model="current.derivative"
v-bind="disclosureUpdateProps('derivative_work')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('derivative_work', status)
"
/>
<PhotosensitivityDisclosureCard
v-if="isDisclosureVisible('epilepsy_triggers')"
v-model="current.photosensitivity"
v-bind="disclosureUpdateProps('epilepsy_triggers')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('epilepsy_triggers', status)
"
/>
<SystemInteractionsDisclosureCard
v-if="isDisclosureVisible('system_interactions')"
v-model="current.systemInteractions"
v-bind="disclosureUpdateProps('system_interactions')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('system_interactions', status)
"
/>
<ArchivedDisclosureCard
v-if="isDisclosureVisible('archived')"
v-model="current.archived"
:project-title="project.title"
v-bind="disclosureUpdateProps('archived')"
@set-lock-status="
(status: DisclosureLockStatus) => setDisclosureLockStatus('archived', status)
"
/>
</div>
<UnsavedChangesPopup
:original="savedSnapshot"
:modified="currentSnapshot"
:saving="saving"
:can-save="canSave"
:save-disabled-reason="saveDisabledReason"
@reset="reset"
@save="save"
/>
</template>
</div>
</template>
@@ -28,6 +28,7 @@
import { SettingsIcon } from '@modrinth/assets'
import {
ButtonLink,
commonProjectSettingsMessages,
EnvironmentMigration,
injectProjectPageContext,
LoadingIndicator,
@@ -36,6 +37,8 @@ import { isStaff } from '@modrinth/utils'
const { currentMember, projectV2, projectV3 } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.environment)
const showEnvironmentMigration = computed(() => {
return isStaff(currentMember.value?.user)
})
@@ -1,5 +1,6 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<Modal
v-if="currentMember"
ref="modal_edit_item"
@@ -18,12 +19,7 @@
:accept="acceptFileTypes"
:max-size="5242880"
aria-label="Replace image"
@change="
(x) => {
editFile = x[0]
showPreviewImage()
}
"
@change="replaceEditFile"
>
<TransferIcon aria-hidden="true" />
</FileButton>
@@ -294,6 +290,7 @@ import {
import {
Button,
ButtonLink,
commonProjectSettingsMessages,
ConfirmModal,
DropArea,
FileButton,
@@ -304,6 +301,8 @@ import {
useFormatDateTime,
} from '@modrinth/ui'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
const formatDate = useFormatDateTime({
@@ -320,16 +319,9 @@ const {
deleteGalleryItem: deleteGalleryItemMutation,
} = injectProjectPageContext()
const title = `${project.value.title} - Gallery`
const description = `View ${project.value.gallery?.length ?? 0} images of ${project.value.title} on Modrinth.`
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
})
useProjectSettingsHeadTitle(commonProjectSettingsMessages.gallery)
const aiImageWarningModal = ref(null)
const modal_edit_item = ref(null)
const modal_confirm = ref(null)
@@ -387,14 +379,35 @@ const resetEdit = () => {
previewImage.value = null
}
const handleFiles = (files) => {
const handleFiles = async (files) => {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
resetEdit()
editFile.value = files[0]
editFile.value = file
showPreviewImage()
modal_edit_item.value.show()
}
const replaceEditFile = async (files) => {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
editFile.value = file
showPreviewImage()
}
const showPreviewImage = () => {
const reader = new FileReader()
if (editFile.value instanceof Blob) {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import {
commonProjectSettingsMessages,
ConfirmLeaveModal,
defineMessages,
IconSelect,
@@ -17,6 +18,8 @@ const { formatMessage } = useVIntl()
const { projectV2: project, patchProject } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => ({
title: project.value.title,
@@ -1,12 +1,13 @@
<template>
<div>
<AiImageWarningModal ref="aiImageWarningModal" />
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
:title="formatMessage(messages.deleteConfirmationTitle)"
:description="formatMessage(messages.deleteConfirmationDescription)"
:has-to-type="true"
:confirmation-text="project.title"
proceed-label="Delete"
:confirmation-text="project.name"
:proceed-label="formatMessage(messages.deleteConfirmationProceedText)"
@proceed="deleteProject"
/>
<section class="universal-card">
@@ -35,9 +36,7 @@
</label>
<div class="text-input-wrapper !w-full">
<div class="text-input-wrapper__before">
<span class="hidden sm:inline">https://modrinth.com</span>/{{
$getProjectTypeForUrl(project.project_type, project.loaders)
}}/
<span class="hidden sm:inline">https://modrinth.com</span>/{{ projectTypeForUrl }}/
</div>
<StyledInput
id="project-slug"
@@ -61,9 +60,10 @@
:disabled="!hasPermission"
resize="vertical"
/>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
<div v-if="summaryWarning" class="my-2">
<SettingsInlineWarning>
{{ summaryWarning }}
</SettingsInlineWarning>
</div>
</div>
@@ -77,7 +77,7 @@
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
:alt="project.name"
size="md"
class="project__icon"
/>
@@ -182,52 +182,6 @@
</div>
</template>
<template
v-if="
!isServerProject &&
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<Combobox
v-model="clientSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Combobox
v-model="serverSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
</template>
<div id="visibility">
<label>
<span class="label__title">Visibility</span>
@@ -243,40 +197,48 @@
</div>
</div>
</div>
</section>
<div v-if="!isServerProject" class="mt-4 flex flex-col gap-2">
<div class="grid grid-cols-[1fr_auto] items-center gap-6">
<label for="project-monetization-toggle">
<span class="mb-1 block text-lg font-semibold text-contrast">Monetization</span>
<span class="block">
When enabled, this project can earn revenue through Modrinth's
<nuxt-link to="/legal/cmp-info" target="_blank" class="text-link"
>Rewards Program</nuxt-link
>. If you don't want to (or can't for legal reasons) earn revenue from this project,
you can turn it off here.
</span>
</label>
<Toggle
id="project-monetization-toggle"
v-model="monetizationEnabled"
:disabled="monetizationToggleDisabled"
/>
<div class="mt-6 flex flex-col gap-4">
<h2 class="m-0 text-2xl font-semibold">
{{ formatMessage(messages.dangerZone) }}
</h2>
<SettingsToggleCard
v-if="!isServerProject"
v-model="monetizationEnabled"
:disabled="monetizationToggleDisabled"
:title="formatMessage(messages.monetizationTitle)"
>
<p>
<IntlFormatted :message-id="messages.monetizationDescription">
<template #rewards-program-link="{ children }">
<nuxt-link
to="/legal/cmp-info"
target="_blank"
class="smart-clickable:allow-pointer-events text-link"
>
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</p>
<div v-if="isForceDemonetized" class="mt-2">
<SettingsInlineWarning>
<IntlFormatted :message-id="messages.monetizationDisabledDescription">
<template #contact-support-link="{ children }">
<a
class="smart-clickable:allow-pointer-events text-orange underline hover:brightness-110"
href="https://support.modrinth.com"
target="_blank"
rel="noopener noreferrer"
>
<component :is="() => normalizeChildren(children)" />
</a>
</template>
</IntlFormatted>
</SettingsInlineWarning>
</div>
<div v-if="isForceDemonetized" class="mt-2 flex flex-wrap items-center gap-2 text-orange">
<TriangleAlertIcon aria-hidden="true" />
<span>
Your project is not eligible for monetization. If you think this is a mistake, please
<a
class="text-orange underline hover:brightness-110"
href="https://support.modrinth.com"
target="_blank"
rel="noopener noreferrer"
>
contact support</a
>.
</span>
</div>
<div v-if="isStaff" class="mt-2">
<div v-if="isStaff" class="smart-clickable:allow-pointer-events mt-2">
<Button
v-if="!isForceDemonetized"
type="colored"
@@ -296,29 +258,33 @@
Allow monetization
</Button>
</div>
</div>
</section>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete project</span>
</h3>
</div>
<p>
Removes your project from Modrinth's servers and search. Clicking on this will delete your
project, so be extra careful!
</p>
<Button
type="colored"
color="red"
:disabled="!hasDeletePermission"
@click="$refs.modal_confirm.show()"
>
<TrashIcon aria-hidden="true" />
Delete project
</Button>
</section>
</SettingsToggleCard>
<SettingsOptionCard :title="formatMessage(messages.deleteProjectTitle)">
<p>
<IntlFormatted :message-id="messages.deleteProjectDescription1">
<template #emphasis="{ children }">
<span class="font-medium text-red"
><component :is="() => normalizeChildren(children)"
/></span>
</template>
</IntlFormatted>
</p>
<p>
{{ formatMessage(messages.deleteProjectDescription2) }}
</p>
<template #actions>
<Button
type="colored"
color="red"
:disabled="!hasDeletePermission"
@click="$refs.modal_confirm.show()"
>
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteProjectButton) }}
</Button>
</template>
</SettingsOptionCard>
</div>
<UnsavedChangesPopup
:original="original"
:modified="modified"
@@ -331,56 +297,64 @@
</template>
<script setup>
import { ImageIcon, ScaleIcon, TrashIcon, TriangleAlertIcon, UploadIcon } from '@modrinth/assets'
import { ImageIcon, ScaleIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import {
Avatar,
Button,
Combobox,
commonProjectSettingsMessages,
ConfirmLeaveModal,
ConfirmModal,
defineMessages,
FileButton,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
IntlFormatted,
normalizeChildren,
SettingsInlineWarning,
SettingsOptionCard,
SettingsToggleCard,
StyledInput,
Toggle,
UnsavedChangesPopup,
useFormatBytes,
usePageLeaveSafety,
useVIntl,
} from '@modrinth/ui'
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { fileIsValid, formatProjectStatus } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import { useAuth } from '~/composables/auth.js'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
const auth = await useAuth()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const {
projectV2: project,
projectV3,
projectV3: project,
currentMember,
patchProject,
patchProjectV3,
patchIcon,
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
const flags = useFeatureFlags()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
const tags = useGeneratedState()
const router = useNativeRouter()
const formatBytes = useFormatBytes()
const name = ref(project.value.title)
const slug = ref(project.value.slug)
const summary = ref(project.value.description)
const name = ref(project.value.name)
const slug = ref(project.value.slug ?? '')
const summary = ref(project.value.summary)
const icon = ref(null)
const previewImage = ref(null)
const clientSide = ref(project.value.client_side)
const serverSide = ref(project.value.server_side)
const deletedIcon = ref(false)
const visibility = ref(
tags.value.approvedStatuses.includes(project.value.status)
@@ -406,12 +380,17 @@ const isForceDemonetized = computed(() => project.value.monetization_status ===
// Server project specific refs
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const isServerProject = computed(() => project.value?.minecraft_server != null)
const projectTypeForUrl = computed(() => {
if (isServerProject.value) return 'server'
const type = project.value.project_types?.[0] ?? 'mod'
return getProjectTypeForUrl(type, project.value.loaders)
})
const bannerPreview = ref(null)
const deletedBanner = ref(false)
const bannerFile = ref(null)
const bannerGalleryImage = computed(() =>
project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME),
project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME),
)
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
@@ -436,53 +415,41 @@ const summaryWarning = computed(() => {
return null
})
const sideTypeOptions = [
{ value: 'required', label: 'Required' },
{ value: 'optional', label: 'Optional' },
{ value: 'unsupported', label: 'Unsupported' },
]
const visibilityOptions = computed(() =>
tags.value.approvedStatuses.map((status) => {
const subLabel = () => {
switch (status) {
case 'approved':
return 'Visible via URL, on your profile, and in search.'
case 'archived':
return 'Visible via URL, on your profile, and in search, but marked as archived.'
case 'unlisted':
return 'Visible via URL only. Not shown on your profile or in search.'
case 'private':
return 'Not publicly visible. Only accessible to project members.'
default:
return ''
tags.value.approvedStatuses
.filter((status) => status !== 'archived')
.map((status) => {
const subLabel = () => {
switch (status) {
case 'approved':
return 'Visible via URL, on your profile, and in search.'
case 'unlisted':
return 'Visible via URL only. Not shown on your profile or in search.'
case 'private':
return 'Not publicly visible. Only accessible to project members.'
default:
return ''
}
}
}
return {
value: status,
label: formatProjectStatus(status),
subLabel: subLabel(),
}
}),
return {
value: status,
label: formatProjectStatus(status),
subLabel: subLabel(),
}
}),
)
const basePatchData = computed(() => {
const data = {}
if (name.value !== project.value.title) {
data.title = name.value.trim()
if (name.value !== project.value.name) {
data.name = name.value.trim()
}
if (slug.value !== project.value.slug) {
if (slug.value !== (project.value.slug ?? '')) {
data.slug = slug.value.trim()
}
if (summary.value !== project.value.description) {
data.description = summary.value.trim()
}
if (clientSide.value !== project.value.client_side) {
data.client_side = clientSide.value
}
if (serverSide.value !== project.value.server_side) {
data.server_side = serverSide.value
if (summary.value !== project.value.summary) {
data.summary = summary.value.trim()
}
if (tags.value.approvedStatuses.includes(project.value.status)) {
if (visibility.value !== project.value.status) {
@@ -505,11 +472,9 @@ const basePatchData = computed(() => {
const saving = ref(false)
const original = computed(() => ({
name: project.value.title,
slug: project.value.slug,
summary: project.value.description,
clientSide: project.value.client_side,
serverSide: project.value.server_side,
name: project.value.name,
slug: project.value.slug ?? '',
summary: project.value.summary,
visibility: tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status,
@@ -524,8 +489,6 @@ const modified = computed(() => ({
name: name.value,
slug: slug.value,
summary: summary.value,
clientSide: clientSide.value,
serverSide: serverSide.value,
visibility: visibility.value,
icon: icon.value,
deletedIcon: deletedIcon.value,
@@ -541,11 +504,9 @@ const hasChanges = computed(() =>
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
function resetChanges() {
name.value = project.value.title
slug.value = project.value.slug
summary.value = project.value.description
clientSide.value = project.value.client_side
serverSide.value = project.value.server_side
name.value = project.value.name
slug.value = project.value.slug ?? ''
summary.value = project.value.summary
visibility.value = tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status
@@ -561,7 +522,7 @@ function resetChanges() {
async function updateMonetizationStatus(status) {
loadingModeratorMonetization.value = true
try {
await patchProject({ monetization_status: status })
await patchProjectV3({ monetization_status: status })
} finally {
loadingModeratorMonetization.value = false
}
@@ -570,10 +531,10 @@ async function updateMonetizationStatus(status) {
async function handleSave() {
saving.value = true
try {
const hasV2Changes = Object.keys(basePatchData.value).length > 0
const hasPatchChanges = Object.keys(basePatchData.value).length > 0
if (hasV2Changes) {
await patchProject(basePatchData.value)
if (hasPatchChanges) {
await patchProjectV3(basePatchData.value)
}
if (deletedIcon.value) {
@@ -597,9 +558,17 @@ async function handleSave() {
}
}
const showPreviewImage = (files) => {
const showPreviewImage = async (files) => {
const file = files[0]
if (!file) {
return
}
if (await fileDeclaresAi(file)) {
aiImageWarningModal.value?.show()
return
}
const reader = new FileReader()
icon.value = files[0]
icon.value = file
deletedIcon.value = false
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
@@ -630,18 +599,16 @@ const uploadBanner = async () => {
if (!bannerFile.value) return
try {
// First, delete existing banner image if there is one
const existingBanner = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
const existingBanner = project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME)
if (existingBanner) {
await labrinth.projects_v2.deleteGalleryImage(project.value.id, existingBanner.url)
await labrinth.projects_v3.deleteGalleryImage(project.value.id, existingBanner.url)
}
// Upload new banner as gallery image with special title
const ext = bannerFile.value.type.split('/').pop() ?? 'png'
await labrinth.projects_v2.createGalleryImage(project.value.id, bannerFile.value, {
await labrinth.projects_v3.createGalleryImage(project.value.id, bannerFile.value, {
ext,
featured: false,
title: MC_SERVER_BANNER_NAME,
name: MC_SERVER_BANNER_NAME,
})
await invalidate()
@@ -661,9 +628,9 @@ const uploadBanner = async () => {
const deleteBanner = async () => {
try {
const bannerImage = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
const bannerImage = project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME)
if (bannerImage) {
await labrinth.projects_v2.deleteGalleryImage(project.value.id, bannerImage.url)
await labrinth.projects_v3.deleteGalleryImage(project.value.id, bannerImage.url)
await invalidate()
addNotification({
title: 'Banner removed',
@@ -681,9 +648,7 @@ const deleteBanner = async () => {
}
const deleteProject = async () => {
await useBaseFetch(`project/${project.value.id}`, {
method: 'DELETE',
})
await labrinth.projects_v3.deleteProject(project.value.id)
await initUserProjects()
await router.push('/dashboard/projects')
addNotification({
@@ -700,9 +665,7 @@ const markIconForDeletion = () => {
}
const deleteIcon = async () => {
await useBaseFetch(`project/${project.value.id}/icon`, {
method: 'DELETE',
})
await labrinth.projects_v3.deleteIcon(project.value.id)
await invalidate()
addNotification({
title: 'Project icon removed',
@@ -710,6 +673,55 @@ const deleteIcon = async () => {
type: 'success',
})
}
const messages = defineMessages({
dangerZone: {
id: 'project.settings.danger-zone',
defaultMessage: 'Danger zone',
},
monetizationTitle: {
id: 'project.settings.monetization.title',
defaultMessage: 'Monetization',
},
monetizationDescription: {
id: 'project.settings.monetization.description',
defaultMessage: `Projects on Modrinth are automatically enrolled in the <rewards-program-link>Rewards Program</rewards-program-link>. If you don't want to (or can't for legal reasons) earn revenue from this project, you can turn it off here.`,
},
monetizationDisabledDescription: {
id: 'project.settings.monetization.disabled-description',
defaultMessage: `This project is not eligible for monetization. If you think this is a mistake, please <contact-support-link>contact support</contact-support-link>.`,
},
deleteProjectTitle: {
id: 'project.settings.delete-project.title',
defaultMessage: 'Delete project',
},
deleteProjectButton: {
id: 'project.settings.delete-project.button',
defaultMessage: 'Delete project',
},
deleteProjectDescription1: {
id: 'project.settings.delete-project.description.1',
defaultMessage:
'Permanently deletes this project from Modrinth. Deleted projects <emphasis>cannot be recovered</emphasis> by Modrinth staff or support.',
},
deleteProjectDescription2: {
id: 'project.settings.delete-project.description.2',
defaultMessage:
'Files uploaded to this project that are actively used in Modpacks hosted on Modrinth may continue to exist.',
},
deleteConfirmationTitle: {
id: 'project.settings.delete-project.confirmation.title',
defaultMessage: 'Are you sure you want to delete this project?',
},
deleteConfirmationDescription: {
id: 'project.settings.delete-project.confirmation.description',
defaultMessage: `If you proceed, all of this project's information and all of its versions will be immediately deleted from our database. None of it is recoverable later if you change your mind. Consider just setting your project to be Private for a less permanent option.`,
},
deleteConfirmationProceedText: {
id: 'project.settings.delete-project.confirmation.proceed-text',
defaultMessage: 'Permanently delete project',
},
})
</script>
<style lang="scss" scoped>
@@ -158,6 +158,7 @@ import {
Checkbox,
Combobox,
type ComboboxOption,
commonProjectSettingsMessages,
ConfirmLeaveModal,
injectProjectPageContext,
StyledInput,
@@ -170,6 +171,8 @@ import { computed } from 'vue'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.license)
const licenseOptions: ComboboxOption<string>[] = builtinLicenses.map((license) => ({
value: license.short,
label: license.friendly,
@@ -288,6 +288,7 @@ import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@
import {
Button,
Combobox,
commonProjectSettingsMessages,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
@@ -313,6 +314,8 @@ const {
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.links)
const issuesUrl = ref(project.value.issues_url)
const sourceUrl = ref(project.value.source_url)
const wikiUrl = ref(project.value.wiki_url)
@@ -568,6 +568,7 @@ import {
Card,
Checkbox,
Combobox,
commonProjectSettingsMessages,
ConfirmModal,
IconButton,
injectModrinthClient,
@@ -592,6 +593,8 @@ const {
invalidate,
} = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.members)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const auth = await useAuth()
@@ -19,6 +19,7 @@ import {
Combobox,
type ComboboxOption,
commonMessages,
commonProjectSettingsMessages,
ConfirmModal,
createAttributionGroupTitle,
defineMessage,
@@ -55,6 +56,8 @@ const queryClient = useQueryClient()
const deleteAllGroupsModalRef =
useTemplateRef<InstanceType<typeof ConfirmModal>>('deleteAllGroupsModalRef')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.permissions)
type SortType = 'status' | 'most_files' | 'recently_edited' | 'rejected'
const searchQuery = ref('')
@@ -169,6 +169,7 @@
import { InfoIcon, RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
import {
Combobox,
commonProjectSettingsMessages,
ConfirmLeaveModal,
defineMessages,
IconButton,
@@ -271,6 +272,8 @@ const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.server)
const javaAddress = ref('')
const bedrockAddress = ref('')
const bedrockPort = ref(19132)
@@ -2,8 +2,10 @@
import {
Admonition,
Checkbox,
commonMessages,
ConfirmLeaveModal,
defineMessages,
EmptyState,
formatCategory,
formatCategoryHeader,
formatProjectTypeSentence,
@@ -51,9 +53,9 @@ const messages = defineMessages({
id: 'project.settings.tags.title',
defaultMessage: 'Tags',
},
uploadVersionFirst: {
id: 'project.settings.tags.upload-version-first',
defaultMessage: 'Please upload a version first in order to select tags!',
uploadVersionFirstHeading: {
id: 'project.settings.tags.upload-version-first.heading',
defaultMessage: 'Upload versions before adding tags',
},
categoriesDescription: {
id: 'project.settings.tags.categories-description',
@@ -145,6 +147,8 @@ const groupDescriptionMessages: Record<string, MessageDescriptor> = {
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
useProjectSettingsHeadTitle(messages.title)
const formatCategoryName = (categoryName: string) => formatCategory(formatMessage, categoryName)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
@@ -373,9 +377,12 @@ const toggleFeatured = (tag: string) => {
<h2 class="mb-4 mt-0 text-2xl font-semibold">
{{ formatMessage(messages.title) }}
</h2>
<p v-if="!canSelectTags" class="known-errors">
{{ formatMessage(messages.uploadVersionFirst) }}
</p>
<EmptyState
v-if="!canSelectTags"
type="no-documents"
:heading="formatMessage(messages.uploadVersionFirstHeading)"
:description="formatMessage(commonMessages.uploadVersionsEmptyStateDescription)"
/>
<div v-else class="flex flex-col gap-4">
<Admonition v-if="allTagsSelectedWarning" type="critical" :body="allTagsSelectedWarning" />
<Admonition v-else-if="tooManyTagsWarning" type="warning" :body="tooManyTagsWarning" />
@@ -390,10 +397,10 @@ const toggleFeatured = (tag: string) => {
:key="group.id"
class="rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
>
<h3 class="mb-1 mt-0 text-base font-semibold text-contrast">
<h3 class="mb-1 mt-0 text-lg font-semibold text-contrast">
{{ group.title }}
</h3>
<p v-if="group.description" class="mb-3 mt-0 text-sm">
<p v-if="group.description" class="mb-3 mt-0">
{{ group.description }}
</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
@@ -413,7 +420,7 @@ const toggleFeatured = (tag: string) => {
<div class="rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="mb-1 flex items-center gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.featuredTags) }}
</h3>
<TagItem
@@ -427,10 +434,10 @@ const toggleFeatured = (tag: string) => {
{{ current.featuredTags.length }}/{{ MAX_FEATURED_TAGS }}
</TagItem>
</div>
<p class="mb-3 mt-0 text-sm">
<p class="mb-3 mt-0">
{{ formatMessage(messages.featuredTagsDescription) }}
</p>
<p v-if="current.selectedTags.length < 1" class="m-0 text-sm text-secondary">
<p v-if="current.selectedTags.length < 1" class="m-0 text-secondary">
{{ formatMessage(messages.selectAtLeastOneCategory) }}
</p>
<div v-else class="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
@@ -381,6 +381,8 @@ const {
cdnDownloadReason,
} = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.versions)
// Load versions on mount (client-side)
onMounted(() => {
loadVersions()
@@ -37,7 +37,7 @@ import type { LocationQueryRaw } from 'vue-router'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import { projectQueryOptions } from '~/composables/queries/project'
import { projectQueryOptions, warmProjectCheckCaches } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import type {
ServerInstallModalHandle,
@@ -67,14 +67,15 @@ let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
const handleProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const slug = result.slug || result.project_id
const projectId = result.project_id
prefetchTimeout = useTimeoutFn(
() => {
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client))
warmProjectCheckCaches(queryClient, result)
queryClient.prefetchQuery(projectQueryOptions.v2(projectId, client))
queryClient.prefetchQuery(projectQueryOptions.v3(projectId, client))
queryClient.prefetchQuery(projectQueryOptions.members(projectId, client))
queryClient.prefetchQuery(projectQueryOptions.dependencies(projectId, client))
queryClient.prefetchQuery(projectQueryOptions.versionsV3(projectId, client))
},
HOVER_DURATION_TO_PREFETCH_MS,
{ immediate: false },
@@ -83,12 +84,13 @@ const handleProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject)
}
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const slug = result.slug || result.project_id
const projectId = result.project_id
prefetchTimeout = useTimeoutFn(
async () => {
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
queryClient.prefetchQuery(projectQueryOptions.v3(slug, client))
warmProjectCheckCaches(queryClient, result)
queryClient.prefetchQuery(projectQueryOptions.v2(projectId, client))
queryClient.prefetchQuery(projectQueryOptions.v3(projectId, client))
const content = result.minecraft_java_server?.content
if (content?.kind === 'modpack' && content.version_id) {
@@ -416,6 +418,14 @@ const advancedFiltersCollapsed = computed({
},
})
const dismissedPhotosensitivityFilterWarning = computed({
get: () => flags.value.dismissedPhotosensitivityFilterWarning,
set: (value) => {
flags.value.dismissedPhotosensitivityFilterWarning = value
saveFeatureFlags()
},
})
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
debug('projectTypeId:', projectTypeId.value)
@@ -437,6 +447,15 @@ const searchState = useBrowseSearch({
})
setBrowseSearchState(searchState)
// Warm check caches for every visible hit so clicking a result skips /project/{slug}/check
watch(
[() => searchState.projectHits.value, () => searchState.serverHits.value],
([projectHits, serverHits]) => {
warmProjectCheckCaches(queryClient, [...projectHits, ...serverHits])
},
{ immediate: true },
)
watch(
() =>
searchState.isServerType.value
@@ -525,6 +544,7 @@ provideBrowseManager({
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
advancedFiltersCollapsed,
dismissedPhotosensitivityFilterWarning,
displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions,
+94 -7
View File
@@ -1,6 +1,7 @@
<template>
<div class="markdown-body">
<h1>Content Rules</h1>
<p><em>Last modified: August 12, 2026</em></p>
<p>
These Content Rules are to be considered part of our
@@ -72,8 +73,12 @@
<p>
Projects, a form of Content, must make a clear and honest attempt to describe their purpose in
designated areas on the project page. Necessary information must not be obscured in any way.
Using confusing language or technical jargon when it is not necessary constitutes a violation.
designated areas on the project page. Necessary information must not be obscured in any way or
use confusing language or technical jargon when it is unnecessary.
</p>
<p>
Important information like content disclosures must always be described accurately and
appropriately.
</p>
<h3 id="general-expectations">2.1. General Expectations</h3>
@@ -144,17 +149,22 @@
which have diverged substantially from the original project.
</p>
<p>
When uploading content you have the necessary permissions to distribute but did not author
yourself, you must make a meaningful effort to credit each original source properly.
</p>
<h2 id="miscellaneous">5. Miscellaneous</h2>
<p>
There are certain other small aspects to creating projects that all authors should attempt to
abide by. These will not necessarily always be enforced, but abiding by all will result in a
faster review with fewer potential issues.
abide by. These will not be enforced as strictly, but abiding by all will result fewer
potential issues during project review, and a better project with more potential reach.
</p>
<ol>
<li>
All metadata, including license, client/server-side information, tags, etc. are filled out
correctly and are consistent with information found elsewhere.
All metadata, including license, environment information, tags, content disclosures, etc.
are filled out correctly and are consistent with information found elsewhere.
</li>
<li>
Project titles are only the name of the project, without any other unnecessary filler data.
@@ -164,13 +174,61 @@
repeating the project title.
</li>
<li>All external links lead to public resources that are relevant.</li>
<li>Gallery images are relevant to the project and each contain a title.</li>
<li>
Gallery images are relevant to the project and do not give a false impression of the
project's contents.
</li>
<li>All dependencies must be specified in the Dependencies section of each version.</li>
<li>
"Additional files" are only used for special designated purposes, such as source JAR files.
In other words, separate versions and/or projects are used where appropriate instead of
additional files.
</li>
<li>
Files uploaded to a project should be directly related to the content of the initial project
page or a direct linear upgrade. Files with different functionality should not be uploaded
as new versions within a single project; instead, they should be published as separate
projects.
</li>
<li>
All applicable content disclosures are filled out accurately and maintained with up-to-date
information.
</li>
</ol>
<h2 id="generative-ai">6. Usage of Generative "AI"</h2>
<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>
@@ -186,3 +244,32 @@ useSeoMeta({
ogDescription: description,
})
</script>
<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>
+5 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="markdown-body">
<h1>Terms of Use</h1>
<p><em>Last modified: September 16, 2023</em></p>
<p><em>Last modified: August 13, 2026</em></p>
<h2>Acceptance of the Terms of Use</h2>
<p>
These terms of use are entered into by and between You and Rinth, Inc.
@@ -223,6 +223,10 @@
Use any manual process to monitor or copy any of the material contained in the Service or
for any other unauthorized purpose without our prior written consent.
</li>
<li>
Use any data, content, or materials available on or through the Service to train, develop,
or improve any artificial intelligence or machine learning models.
</li>
<li>
Use any device, software or routine that interferes with the proper working of the Service.
</li>
@@ -217,6 +217,7 @@ import {
Combobox,
type ComboboxOption,
commonMessages,
formatReportType,
injectModrinthClient,
MultiSelect,
type MultiSelectItem,
@@ -361,7 +362,13 @@ const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
const sortedTypes = Array.from(issueTypes).sort()
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
return [
...base,
...sortedTypes.map((type) => ({
value: type,
label: formatReportType(formatMessage, type),
})),
]
})
type ReportedType<T> = T & { report_item_count: number }
@@ -119,7 +119,7 @@ onMounted(() => {
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
>
<nuxt-link :to="`/news`">
<h1 class="m-0 text-3xl font-extrabold hover:underline">News</h1>
<h1 class="m-0 text-3xl font-semibold hover:underline">News</h1>
</nuxt-link>
<div class="flex gap-2">
<NewsletterButton />
@@ -143,7 +143,7 @@ onMounted(() => {
</div>
</div>
<article class="mt-6 flex flex-col gap-4 px-6">
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">
<h2 class="m-0 text-2xl font-semibold leading-tight sm:text-4xl">
{{ article.title }}
</h2>
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
@@ -225,9 +225,19 @@ onMounted(() => {
padding: 0;
}
ul,
ol {
margin-bottom: 0.5rem;
ul,
ol {
margin-top: 0.25rem;
}
}
ul > li:not(:last-child),
ol > li:not(:last-child) {
margin-bottom: 0.5rem;
margin-bottom: 0.25rem;
}
ul,
@@ -302,8 +312,10 @@ onMounted(() => {
h1,
h2 {
font-weight: 600;
a {
font-weight: 800;
font-weight: 600;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="page">
<h1 class="m-0 text-3xl font-extrabold">Changelog</h1>
<h1 class="m-0 text-3xl font-semibold">Changelog</h1>
<p class="my-3">Keep up-to-date on what's new with Modrinth.</p>
<NuxtPage />
</div>
+1 -1
View File
@@ -44,7 +44,7 @@ useSeoMeta({
<div class="page py-6">
<div class="flex flex-wrap items-center justify-between gap-4 px-6">
<div>
<h1 class="m-0 text-3xl font-extrabold">News</h1>
<h1 class="m-0 text-3xl font-semibold">News</h1>
</div>
<div class="flex gap-2">
<NewsletterButton />
@@ -241,13 +241,14 @@ import {
useVIntl,
} from '@modrinth/ui'
import type { Organization, ProjectStatus, ProjectType } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavStack from '~/components/ui/NavStack.vue'
import OrganizationPageHeader from '~/components/ui/OrganizationPageHeader.vue'
import { warmProjectCheckCaches } from '~/composables/queries/project'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
import {
OrganizationContext,
@@ -296,6 +297,7 @@ if (route.path.includes('settings')) {
const routeHasSettings = computed(() => route.path.includes('settings'))
const client = injectModrinthClient()
const queryClient = useQueryClient()
const {
data: organization,
@@ -346,6 +348,14 @@ const {
placeholderData: [],
})
watch(
projects,
(list) => {
warmProjectCheckCaches(queryClient, list)
},
{ immediate: true },
)
const refresh = async () => {
await Promise.all([refreshOrganization(), refreshProjects()])
}
+29 -8
View File
@@ -106,7 +106,7 @@
<div class="card-shadow flex flex-col gap-4 rounded-xl bg-bg-raised p-6">
<template v-if="!prefilled || !currentItemValid">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.whatContentType) }}
</span>
<RadioButtons
@@ -124,7 +124,7 @@
</RadioButtons>
</div>
<div class="flex flex-col gap-2" :class="{ hidden: !reportItem }">
<span class="text-lg font-bold text-contrast">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(messages.whatContentId, {
item: formatReportItemType(formatMessage, reportItem),
@@ -160,7 +160,7 @@
<AutoLink
:to="itemLink"
target="_blank"
class="flex items-center gap-1 font-bold text-contrast hover:underline"
class="flex items-center gap-1 font-semibold text-contrast hover:underline"
>
<Avatar
v-if="typeof itemIcon === 'string'"
@@ -203,7 +203,7 @@
</template>
<template v-else>
<div class="flex flex-col gap-2" :class="{ hidden: !reportItemID }">
<span class="text-lg font-bold text-contrast">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(messages.whatReportReason, {
item: formatReportItemType(formatMessage, reportItem),
@@ -211,7 +211,7 @@
}}
</span>
<RadioButtons v-slot="{ item }" v-model="reportType" :items="reportTypes">
{{ item === 'copyright' ? 'Reuploaded work' : capitalizeString(item) }}
{{ formatReportType(formatMessage, item) }}
</RadioButtons>
</div>
<div
@@ -236,7 +236,7 @@
</div>
</div>
<div :class="{ hidden: !reportType }">
<span class="text-lg font-bold text-contrast">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.reportBodyTitle) }}
</span>
<p class="m-0 leading-tight text-secondary">
@@ -247,6 +247,7 @@
<MarkdownEditor
v-model="reportBody"
placeholder=""
:disabled="disableReporting"
:on-image-upload="onImageUpload"
/>
</div>
@@ -255,7 +256,7 @@
id="submit-button"
type="colored"
color="brand"
:disabled="submitLoading || !canSubmit"
:disabled="submitLoading || !canSubmit || disableReporting"
@click="submitReport"
>
<SendIcon aria-hidden="true" />
@@ -291,6 +292,7 @@ import {
defineMessage,
defineMessages,
formatReportItemType,
formatReportType,
injectNotificationManager,
IntlFormatted,
MarkdownEditor,
@@ -410,10 +412,18 @@ async function fetchItem() {
}
const reportItems = ['project', 'version', 'user']
const reportTypes = computed(() => tags.value.reportTypes)
const reportTypes = computed(() => {
const types = [...tags.value.reportTypes]
if (reportItem.value === 'project') {
types.push('missing-disclosure')
}
return types
})
const disableReporting = computed(() => reportType.value === 'missing-disclosure')
const canSubmit = computed(() => {
return (
!disableReporting.value &&
reportItem.value !== '' &&
reportItemID.value !== '' &&
reportType.value !== '' &&
@@ -546,6 +556,17 @@ const warnings: Record<string, MessageDescriptor[]> = {
'Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted.',
}),
],
'missing-disclosure': [
defineMessage({
id: 'report.note.missing-disclosure.1',
defaultMessage: `Content disclosures are a new feature we've just added. We're giving creators a grace period to get their disclosures up-to-date before we begin accepting reports for missing or incorrect disclosures.`,
}),
defineMessage({
id: 'report.note.missing-disclosure.2',
defaultMessage:
'Check back later once the grace period has ended. Reports of this type are not being accepted at this time.',
}),
],
}
const messages = defineMessages({
+5 -1
View File
@@ -24,6 +24,7 @@ import { useQueryClient } from '@tanstack/vue-query'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import { warmProjectCheckCaches } from '~/composables/queries/project'
const route = useNativeRoute()
const client = injectModrinthClient()
@@ -62,7 +63,7 @@ try {
// Let the mounted layout's useQuery surface errors; do not fail route setup.
}
await Promise.allSettled([
const [projectsResult] = await Promise.allSettled([
queryClient.ensureQueryData({
queryKey: ['user', userId.value, 'projects'],
queryFn: () => userProfile.getProjects(userId.value),
@@ -80,6 +81,9 @@ await Promise.allSettled([
}),
])
if (projectsResult.status === 'fulfilled') {
warmProjectCheckCaches(queryClient, projectsResult.value)
}
const title = computed(() =>
prefetchedUser ? `${prefetchedUser.username} - Modrinth` : 'Modrinth',
)