mirror of
https://github.com/modrinth/code.git
synced 2026-09-04 05:48:57 +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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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()])
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user