Revert "Author Validation Improvements (#3970)" (#4024)

This reverts commit 44267619b6.
This commit is contained in:
Prospector
2025-07-19 15:04:47 -07:00
committed by GitHub
parent bcf46d440b
commit 0f755b94ce
23 changed files with 661 additions and 2213 deletions

View File

@@ -34,7 +34,7 @@ const enabledLocales: string[] = [];
/**
* Overrides for the categories of the certain locales.
*/
const localesCategoriesOverrides: Partial = {
const localesCategoriesOverrides: Partial<Record<string, "fun" | "experimental">> = {
"en-x-pirate": "fun",
"en-x-updown": "fun",
"en-x-lolcat": "fun",
@@ -260,28 +260,21 @@ export default defineNuxtConfig({
const omorphiaLocales: string[] = [];
const omorphiaLocaleSets = new Map<string, { files: { from: string }[] }>();
const externalLocales = [
"node_modules/@modrinth/ui/src/locales/en-US",
"node_modules/@modrinth/moderation/locales/en-US",
];
for await (const localeDir of globIterate("node_modules/@modrinth/ui/src/locales/*", {
posix: true,
})) {
const tag = basename(localeDir);
omorphiaLocales.push(tag);
for (const localePath of externalLocales) {
for await (const localeDir of globIterate(localePath, {
posix: true,
})) {
const tag = basename(localeDir);
omorphiaLocales.push(tag);
const localeFiles: { from: string; format?: string }[] = [];
const localeFiles: { from: string; format?: string }[] = [];
omorphiaLocaleSets.set(tag, { files: localeFiles });
omorphiaLocaleSets.set(tag, { files: localeFiles });
for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
localeFiles.push({
from: pathToFileURL(localeFile).toString(),
format: "default",
});
}
for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
localeFiles.push({
from: pathToFileURL(localeFile).toString(),
format: "default",
});
}
}
@@ -308,7 +301,7 @@ export default defineNuxtConfig({
format: "crowdin",
});
} else if (fileName === "meta.json") {
const meta: Record = await fs
const meta: Record<string, { message: string }> = await fs
.readFile(localeFile, "utf8")
.then((date) => JSON.parse(date));
const localeMeta = (locale.meta ??= {});

View File

@@ -1,442 +1,510 @@
<template>
<div v-if="showInvitation" class="universal-card information invited my-4">
<h2>{{ getFormattedMessage(messages.invitationTitle) }}</h2>
<p v-if="currentMember?.project_role">
{{ formatMessage(messages.invitationWithRole, { role: currentMember.project_role }) }}
<div v-if="showInvitation" class="universal-card information invited">
<h2>Invitation to join project</h2>
<p>
You've been invited be a member of this project with the role of '{{ currentMember.role }}'.
</p>
<p v-else>{{ getFormattedMessage(messages.invitationNoRole) }}</p>
<div class="input-group">
<ButtonStyled color="brand">
<button class="brand-button" @click="acceptInvite()">
<CheckIcon />
{{ getFormattedMessage(messages.accept) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="declineInvite">
<XIcon />
{{ getFormattedMessage(messages.decline) }}
</button>
</ButtonStyled>
<button class="iconified-button brand-button" @click="acceptInvite()">
<CheckIcon />
Accept
</button>
<button class="iconified-button danger-button" @click="declineInvite()">
<XIcon />
Decline
</button>
</div>
</div>
<div
v-if="
currentMember &&
visibleNags.length > 0 &&
nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="universal-card my-4"
class="author-actions universal-card mb-4"
>
<div class="flex max-w-full flex-wrap items-center gap-x-6 gap-y-4">
<div class="flex flex-auto flex-wrap items-center gap-x-6 gap-y-4">
<h2 class="my-0 mr-auto">{{ getFormattedMessage(messages.publishingChecklist) }}</h2>
<div class="header__row">
<div class="header__title">
<h2>Publishing checklist</h2>
<div class="checklist">
<span class="checklist__title">Progress:</span>
<div class="checklist__items">
<div
v-for="nag in nags"
:key="`checklist-${nag.id}`"
v-tooltip="nag.title"
:aria-label="nag.title"
:class="'circle ' + (!nag.condition ? 'done' : '') + nag.status"
class="circle"
>
<CheckIcon v-if="!nag.condition" />
<AsteriskIcon v-else-if="nag.status === 'required'" />
<LightBulbIcon v-else-if="nag.status === 'suggestion'" />
<ScaleIcon v-else-if="nag.status === 'review'" />
</div>
</div>
</div>
</div>
<div class="input-group">
<ButtonStyled circular>
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="toggleCollapsed()">
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
</button>
</ButtonStyled>
<button
:class="{ 'not-collapsed': !collapsed }"
class="square-button"
@click="toggleCollapsed()"
>
<DropdownIcon />
</button>
</div>
</div>
<div v-if="!collapsed" class="grid-display width-16 mt-4">
<div v-for="nag in visibleNags" :key="nag.id" class="grid-display__item">
<span class="flex items-center gap-2 font-semibold">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'h-4 w-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
<div v-if="!collapsed" class="grid-display width-16">
<div
v-for="nag in nags.filter((x) => x.condition && !x.hide)"
:key="nag.id"
class="grid-display__item"
>
<span class="label">
<AsteriskIcon
v-if="nag.status === 'required'"
v-tooltip="'Required'"
:class="nag.status"
aria-label="Required"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<LightBulbIcon
v-else-if="nag.status === 'suggestion'"
v-tooltip="'Suggestion'"
:class="nag.status"
aria-label="Suggestion"
/>
<ScaleIcon
v-else-if="nag.status === 'review'"
v-tooltip="'Review'"
:class="nag.status"
aria-label="Review"
/>{{ nag.title }}</span
>
{{ nag.description }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
v-if="nag.link"
:class="{ invisible: nag.link.hide }"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link"
>
{{ getFormattedMessage(nag.link.title) }}
{{ nag.link.title }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<ButtonStyled
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
color="orange"
@click="submitForReview"
<button
v-else-if="nag.action"
:disabled="nag.action.disabled()"
class="btn btn-orange"
@click="nag.action.onClick"
>
<button
:disabled="!canSubmitForReview"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReview) }}
</button>
</ButtonStyled>
<SendIcon />
{{ nag.action.title }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
<script setup>
import {
ChevronRightIcon,
CheckIcon,
XIcon,
AsteriskIcon,
LightBulbIcon,
TriangleAlertIcon,
DropdownIcon,
SendIcon,
ScaleIcon,
DropdownIcon,
} from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
import { nags } from "@modrinth/moderation";
import { ButtonStyled } from "@modrinth/ui";
import { useVIntl, defineMessages, type MessageDescriptor } from "@vintl/vintl";
import type { Nag, NagContext, NagStatus } from "@modrinth/moderation";
import type { Project, User, Version } from "@modrinth/utils";
import type { Component } from "vue";
interface Tags {
rejectedStatuses: string[];
}
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default: null,
},
allMembers: {
type: Object,
default: null,
},
isSettings: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
routeName: {
type: String,
default: "",
},
auth: {
type: Object,
required: true,
},
tags: {
type: Object,
required: true,
},
setProcessing: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "setProcessing function not found",
type: "error",
});
};
},
},
toggleCollapsed: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "toggleCollapsed function not found",
type: "error",
});
};
},
},
updateMembers: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "updateMembers function not found",
type: "error",
});
};
},
},
});
interface Auth {
user: {
id: string;
};
}
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured));
interface Member {
accepted?: boolean;
project_role?: string;
user?: Partial<User>;
}
interface Props {
project: Project;
versions?: Version[];
currentMember?: Member | null;
allMembers?: Member[] | null;
isSettings?: boolean;
collapsed?: boolean;
routeName?: string;
auth: Auth;
tags: Tags;
setProcessing?: (processing: boolean) => void;
toggleCollapsed?: () => void;
updateMembers?: () => void | Promise<void>;
}
const messages = defineMessages({
invitationTitle: {
id: "project-member-header.invitation-title",
defaultMessage: "Invitation to join project",
const nags = computed(() => [
{
condition: props.versions.length < 1,
title: "Upload a version",
id: "upload-version",
description: "At least one version is required for a project to be submitted for review.",
status: "required",
link: {
path: "versions",
title: "Visit versions page",
hide: props.routeName === "type-id-versions",
},
},
invitationWithRole: {
id: "project-member-header.invitation-with-role",
defaultMessage: "You've been invited be a member of this project with the role of '{role}'.",
{
condition:
props.project.body === "" || props.project.body.startsWith("# Placeholder description"),
title: "Add a description",
id: "add-description",
description:
"A description that clearly describes the project's purpose and function is required.",
status: "required",
link: {
path: "settings/description",
title: "Visit description settings",
hide: props.routeName === "type-id-settings-description",
},
},
invitationNoRole: {
id: "project-member-header.invitation-no-role",
defaultMessage:
"You've been invited to join this project. Please accept or decline the invitation.",
{
condition: !props.project.icon_url,
title: "Add an icon",
id: "add-icon",
description:
"Your project should have a nice-looking icon to uniquely identify your project at a glance.",
status: "suggestion",
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
},
},
accept: {
id: "project-member-header.accept",
defaultMessage: "Accept",
{
condition: props.project.gallery.length === 0 || !featuredGalleryImage,
title: "Feature a gallery image",
id: "feature-gallery-image",
description: "Featured gallery images may be the first impression of many users.",
status: "suggestion",
link: {
path: "gallery",
title: "Visit gallery page",
hide: props.routeName === "type-id-gallery",
},
},
decline: {
id: "project-member-header.decline",
defaultMessage: "Decline",
{
hide: props.project.versions.length === 0,
condition: props.project.categories.length < 1,
title: "Select tags",
id: "select-tags",
description: "Select all tags that apply to your project.",
status: "suggestion",
link: {
path: "settings/tags",
title: "Visit tag settings",
hide: props.routeName === "type-id-settings-tags",
},
},
publishingChecklist: {
id: "project-member-header.publishing-checklist",
defaultMessage: "Publishing checklist",
{
condition: !(
props.project.issues_url ||
props.project.source_url ||
props.project.wiki_url ||
props.project.discord_url ||
props.project.donation_urls.length > 0
),
title: "Add external links",
id: "add-links",
description:
"Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.",
status: "suggestion",
link: {
path: "settings/links",
title: "Visit links settings",
hide: props.routeName === "type-id-settings-links",
},
},
submitForReview: {
id: "project-member-header.submit-for-review",
defaultMessage: "Submit for review",
{
hide:
props.project.versions.length === 0 ||
props.project.project_type === "resourcepack" ||
props.project.project_type === "plugin" ||
props.project.project_type === "shader" ||
props.project.project_type === "datapack",
condition:
props.project.client_side === "unknown" ||
props.project.server_side === "unknown" ||
(props.project.client_side === "unsupported" && props.project.server_side === "unsupported"),
title: "Select supported environments",
id: "select-environments",
description: `Select if the ${formatProjectType(
props.project.project_type,
).toLowerCase()} functions on the client-side and/or server-side.`,
status: "required",
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
},
},
submitForReviewDesc: {
id: "project-member-header.submit-for-review-desc",
defaultMessage:
{
condition: props.project.license.id === "LicenseRef-Unknown",
title: "Select license",
id: "select-license",
description: `Select the license your ${formatProjectType(
props.project.project_type,
).toLowerCase()} is distributed under.`,
status: "required",
link: {
path: "settings/license",
title: "Visit license settings",
hide: props.routeName === "type-id-settings-license",
},
},
{
condition: props.project.status === "draft",
title: "Submit for review",
id: "submit-for-review",
description:
"Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.",
status: "review",
link: null,
action: {
onClick: submitForReview,
title: "Submit for review",
disabled: () => nags.value.filter((x) => x.condition && x.status === "required").length > 0,
},
},
resubmitForReview: {
id: "project-member-header.resubmit-for-review",
defaultMessage: "Resubmit for review",
{
hide: props.project.stats === "draft",
condition: props.tags.rejectedStatuses.includes(props.project.status),
title: "Resubmit for review",
id: "resubmit-for-review",
description: `Your project has been ${props.project.status} by
Modrinth's staff. In most cases, you can resubmit for review after
addressing the staff's message.`,
status: "review",
link: {
path: "moderation",
title: "Visit moderation page",
hide: props.routeName === "type-id-moderation",
},
},
resubmitForReviewDesc: {
id: "project-member-header.resubmit-for-review-desc",
defaultMessage:
"Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message.",
},
visitModerationPage: {
id: "project-member-header.visit-moderation-page",
defaultMessage: "Visit moderation page",
},
submitChecklistTooltip: {
id: "project-member-header.submit-checklist-tooltip",
defaultMessage: "You must complete the required steps in the publishing checklist!",
},
successJoin: {
id: "project-member-header.success-join",
defaultMessage: "You have joined the project team",
},
errorJoin: {
id: "project-member-header.error-join",
defaultMessage: "Failed to accept team invitation",
},
successDecline: {
id: "project-member-header.success-decline",
defaultMessage: "You have declined the team invitation",
},
errorDecline: {
id: "project-member-header.error-decline",
defaultMessage: "Failed to decline team invitation",
},
success: {
id: "project-member-header.success",
defaultMessage: "Success",
},
error: {
id: "project-member-header.error",
defaultMessage: "Error",
},
required: {
id: "project-member-header.required",
defaultMessage: "Required",
},
warning: {
id: "project-member-header.warning",
defaultMessage: "Warning",
},
suggestion: {
id: "project-member-header.suggestion",
defaultMessage: "Suggestion",
},
});
]);
const { formatMessage } = useVIntl();
function getNagDescription(nag: Nag): string {
if (typeof nag.description === "function") {
return nag.description(nagContext.value);
}
return formatMessage(nag.description);
}
function getFormattedMessage(message: string | MessageDescriptor): string {
if (typeof message === "string") {
return message;
}
return formatMessage(message);
}
const props = withDefaults(defineProps<Props>(), {
versions: () => [],
currentMember: null,
allMembers: null,
isSettings: false,
collapsed: false,
routeName: "",
});
const emit = defineEmits<{
toggleCollapsed: [];
updateMembers: [];
setProcessing: [processing: boolean];
}>();
const nagContext = computed<NagContext>(() => ({
project: props.project,
versions: props.versions,
currentMember: props.currentMember as User,
currentRoute: props.routeName,
tags: props.tags,
submitProject: submitForReview,
}));
const canSubmitForReview = computed(() => {
return (
applicableNags.value.filter((nag) => nag.status === "required" && !isNagComplete(nag))
.length === 0
);
});
async function submitForReview() {
if (canSubmitForReview) {
await setProcessing(true);
}
}
const applicableNags = computed<Nag[]>(() => {
return nags.filter((nag) => {
return nag.shouldShow(nagContext.value);
});
});
function isNagComplete(nag: Nag): boolean {
const context = nagContext.value;
return !nag.shouldShow(context);
}
const visibleNags = computed<Nag[]>(() => {
const finalNags = applicableNags.value.filter((nag) => !isNagComplete(nag));
if (props.project.status === "draft") {
finalNags.push({
id: "submit-for-review",
title: messages.submitForReview,
description: () => formatMessage(messages.submitForReviewDesc),
status: "special-submit-action",
shouldShow: (ctx) => ctx.project.status === "draft",
});
}
if (props.tags.rejectedStatuses.includes(props.project.status)) {
finalNags.push({
id: "resubmit-for-review",
title: messages.resubmitForReview,
description: (ctx) =>
formatMessage(messages.resubmitForReviewDesc, { status: ctx.project.status }),
status: "special-submit-action",
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
path: "moderation",
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== "type-id-moderation",
},
});
}
return finalNags;
});
function shouldShowLink(nag: Nag): boolean {
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false;
}
function getDefaultIcon(status: NagStatus): Component {
switch (status) {
case "required":
return AsteriskIcon;
case "warning":
return TriangleAlertIcon;
case "suggestion":
return LightBulbIcon;
case "special-submit-action":
return ScaleIcon;
default:
return AsteriskIcon;
}
}
function getStatusTooltip(status: NagStatus): string {
switch (status) {
case "required":
return formatMessage(messages.required);
case "warning":
return formatMessage(messages.warning);
case "suggestion":
return formatMessage(messages.suggestion);
default:
return formatMessage(messages.required);
}
}
const showInvitation = computed<boolean>(() => {
const showInvitation = computed(() => {
if (props.allMembers && props.auth) {
const member = props.allMembers.find((x) => x?.user?.id === props.auth.user.id);
return !!member && !member.accepted;
const member = props.allMembers.find((x) => x.user.id === props.auth.user.id);
return member && !member.accepted;
}
return false;
});
function toggleCollapsed(): void {
if (props.toggleCollapsed) {
props.toggleCollapsed();
} else {
emit("toggleCollapsed");
}
}
const acceptInvite = () => {
acceptTeamInvite(props.project.team);
props.updateMembers();
};
async function updateMembers(): Promise<void> {
if (props.updateMembers) {
await props.updateMembers();
} else {
emit("updateMembers");
}
}
const declineInvite = () => {
removeTeamMember(props.project.team, props.auth.user.id);
props.updateMembers();
};
function setProcessing(processing: boolean): void {
if (props.setProcessing) {
props.setProcessing(processing);
} else {
emit("setProcessing", processing);
const submitForReview = async () => {
if (
!props.acknowledgedMessage ||
nags.value.filter((x) => x.condition && x.status === "required").length === 0
) {
await props.setProcessing();
}
}
async function acceptInvite(): Promise<void> {
try {
setProcessing(true);
await acceptTeamInvite(props.project.team);
await updateMembers();
addNotification({
group: "main",
title: formatMessage(messages.success),
text: formatMessage(messages.successJoin),
type: "success",
});
} catch (error) {
addNotification({
group: "main",
title: formatMessage(messages.error),
text: formatMessage(messages.errorJoin),
type: "error",
});
} finally {
setProcessing(false);
}
}
async function declineInvite(): Promise<void> {
try {
setProcessing(true);
await removeTeamMember(props.project.team, props.auth.user.id);
await updateMembers();
addNotification({
group: "main",
title: formatMessage(messages.success),
text: formatMessage(messages.successDecline),
type: "success",
});
} catch (error) {
addNotification({
group: "main",
title: formatMessage(messages.error),
text: formatMessage(messages.errorDecline),
type: "error",
});
} finally {
setProcessing(false);
}
}
};
</script>
<style lang="scss" scoped>
.duration-250 {
transition-duration: 250ms;
.invited {
}
.author-actions {
margin-top: var(--spacing-card-md);
&:empty {
display: none;
}
.invisible {
visibility: hidden;
}
.header__row {
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
max-width: 100%;
.header__title {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
flex-basis: min-content;
h2 {
margin: 0 auto 0 0;
}
}
button {
svg {
transition: transform 0.25s ease-in-out;
}
&.not-collapsed svg {
transform: rotate(180deg);
}
}
}
.grid-display__item .label {
display: flex;
gap: var(--spacing-card-xs);
align-items: center;
.required {
color: var(--color-red);
}
.suggestion {
color: var(--color-purple);
}
.review {
color: var(--color-orange);
}
}
.checklist {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
flex-wrap: wrap;
max-width: 100%;
.checklist__title {
font-weight: bold;
margin-right: var(--spacing-card-xs);
color: var(--color-text-dark);
}
.checklist__items {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
max-width: 100%;
}
.circle {
--circle-size: 2rem;
--background-color: var(--color-bg);
--content-color: var(--color-gray);
width: var(--circle-size);
height: var(--circle-size);
border-radius: 50%;
background-color: var(--background-color);
display: flex;
justify-content: center;
align-items: center;
svg {
color: var(--content-color);
width: calc(var(--circle-size) / 2);
height: calc(var(--circle-size) / 2);
}
&.required {
--content-color: var(--color-red);
}
&.suggestion {
--content-color: var(--color-purple);
}
&.review {
--content-color: var(--color-orange);
}
&.done {
--background-color: var(--color-green);
--content-color: var(--color-brand-inverted);
}
}
}
}
</style>

View File

@@ -533,69 +533,6 @@
"profile.user-id": {
"message": "User ID: {id}"
},
"project-member-header.accept": {
"message": "Accept"
},
"project-member-header.decline": {
"message": "Decline"
},
"project-member-header.error": {
"message": "Error"
},
"project-member-header.error-decline": {
"message": "Failed to decline team invitation"
},
"project-member-header.error-join": {
"message": "Failed to accept team invitation"
},
"project-member-header.invitation-no-role": {
"message": "You've been invited to join this project. Please accept or decline the invitation."
},
"project-member-header.invitation-title": {
"message": "Invitation to join project"
},
"project-member-header.invitation-with-role": {
"message": "You've been invited be a member of this project with the role of '{role}'."
},
"project-member-header.publishing-checklist": {
"message": "Publishing checklist"
},
"project-member-header.required": {
"message": "Required"
},
"project-member-header.resubmit-for-review": {
"message": "Resubmit for review"
},
"project-member-header.resubmit-for-review-desc": {
"message": "Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message."
},
"project-member-header.submit-checklist-tooltip": {
"message": "You must complete the required steps in the publishing checklist!"
},
"project-member-header.submit-for-review": {
"message": "Submit for review"
},
"project-member-header.submit-for-review-desc": {
"message": "Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published."
},
"project-member-header.success": {
"message": "Success"
},
"project-member-header.success-decline": {
"message": "You have declined the team invitation"
},
"project-member-header.success-join": {
"message": "You have joined the project team"
},
"project-member-header.suggestion": {
"message": "Suggestion"
},
"project-member-header.visit-moderation-page": {
"message": "Visit moderation page"
},
"project-member-header.warning": {
"message": "Warning"
},
"project-type.collection.plural": {
"message": "Collections"
},

View File

@@ -22,10 +22,6 @@
"
:on-image-upload="onUploadHandler"
/>
<div v-if="descriptionWarning" class="flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ descriptionWarning }}
</div>
<div class="input-group markdown-disclaimer">
<button
:disabled="!hasChanges"
@@ -42,8 +38,7 @@
</template>
<script lang="ts" setup>
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import { MIN_DESCRIPTION_CHARS } from "@modrinth/moderation";
import { SaveIcon } from "@modrinth/assets";
import { MarkdownEditor } from "@modrinth/ui";
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
import { computed, ref } from "vue";
@@ -58,17 +53,6 @@ const props = defineProps<{
const description = ref(props.project.body);
const descriptionWarning = computed(() => {
const text = description.value?.trim() || "";
const charCount = text.length;
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`;
}
return null;
});
const patchRequestPayload = computed(() => {
const payload: {
body?: string;

View File

@@ -82,10 +82,6 @@
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
@@ -244,18 +240,9 @@
<script setup>
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
import {
UploadIcon,
SaveIcon,
TrashIcon,
XIcon,
IssuesIcon,
CheckIcon,
TriangleAlertIcon,
} from "@modrinth/assets";
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
import { Multiselect } from "vue-multiselect";
import { ConfirmModal, Avatar } from "@modrinth/ui";
import { MIN_SUMMARY_CHARS } from "@modrinth/moderation";
import FileInput from "~/components/ui/FileInput.vue";
const props = defineProps({
@@ -313,17 +300,6 @@ const hasDeletePermission = computed(() => {
return (props.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT;
});
const summaryWarning = computed(() => {
const text = summary.value?.trim() || "";
const charCount = text.length;
if (charCount < MIN_SUMMARY_CHARS) {
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`;
}
return null;
});
const sideTypes = ["required", "optional", "unsupported"];
const patchData = computed(() => {

View File

@@ -7,16 +7,11 @@
id="project-issue-tracker"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker </span>
<span class="label__title">Issue tracker</span>
<span class="label__description">
A place for users to report bugs, issues, and concerns about your project.
</span>
</label>
<TriangleAlertIcon
v-if="!isIssuesUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-issue-tracker"
v-model="issuesUrl"
@@ -31,16 +26,11 @@
id="project-source-code"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code </span>
<span class="label__title">Source code</span>
<span class="label__description">
A page/repository containing the source code for your project
</span>
</label>
<TriangleAlertIcon
v-if="!isSourceUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-source-code"
v-model="sourceUrl"
@@ -71,14 +61,9 @@
</div>
<div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server.">
<span class="label__title">Discord invite </span>
<span class="label__title">Discord invite</span>
<span class="label__description"> An invitation link to your Discord server. </span>
</label>
<TriangleAlertIcon
v-if="!isDiscordUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-discord-invite"
v-model="discordUrl"
@@ -138,8 +123,7 @@
<script setup>
import { DropdownSelect } from "@modrinth/ui";
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import { isCommonUrl, commonLinkDomains } from "@modrinth/moderation";
import { SaveIcon } from "@modrinth/assets";
const tags = useTags();
@@ -169,21 +153,6 @@ const sourceUrl = ref(props.project.source_url);
const wikiUrl = ref(props.project.wiki_url);
const discordUrl = ref(props.project.discord_url);
const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true;
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues);
});
const isSourceUrlCommon = computed(() => {
if (!sourceUrl.value || sourceUrl.value.trim().length === 0) return true;
return isCommonUrl(sourceUrl.value, commonLinkDomains.source);
});
const isDiscordUrlCommon = computed(() => {
if (!discordUrl.value || discordUrl.value.trim().length === 0) return true;
return isCommonUrl(discordUrl.value, commonLinkDomains.discord);
});
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls));
rawDonationLinks.push({
id: null,

View File

@@ -6,31 +6,11 @@
<span class="label__title size-card-header">Tags</span>
</h3>
</div>
<div
v-if="tooManyTagsWarning && !allTagsSelectedWarning"
class="my-2 flex items-center gap-1.5 text-orange"
>
<TriangleAlertIcon class="my-auto" />
{{ tooManyTagsWarning }}
</div>
<div v-if="multipleResolutionTagsWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ multipleResolutionTagsWarning }}
</div>
<div v-if="allTagsSelectedWarning" class="my-2 flex items-center gap-1.5 text-red">
<TriangleAlertIcon class="my-auto" />
<span>{{ allTagsSelectedWarning }}</span>
</div>
<p>
Accurate tagging is important to help people find your
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<p v-if="project.versions.length === 0" class="known-errors">
Please upload a version first in order to select tags!
</p>
@@ -132,181 +112,145 @@
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { StarIcon, SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import {
formatCategory,
formatCategoryHeader,
formatProjectType,
sortedCategories,
type Project,
} from "@modrinth/utils";
<script>
import { StarIcon, SaveIcon } from "@modrinth/assets";
import { formatCategory, formatCategoryHeader, formatProjectType } from "@modrinth/utils";
import Checkbox from "~/components/ui/Checkbox.vue";
interface Category {
name: string;
header: string;
icon?: string;
project_type: string;
}
export default defineNuxtComponent({
components: {
Checkbox,
SaveIcon,
StarIcon,
},
props: {
project: {
type: Object,
default() {
return {};
},
},
allMembers: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: "main",
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
};
},
},
},
data() {
return {
selectedTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name)),
),
featuredTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name),
),
};
},
computed: {
categoryLists() {
const lists = {};
this.$sortedCategories().forEach((x) => {
if (x.project_type === this.project.actualProjectType) {
const header = x.header;
if (!lists[header]) {
lists[header] = [];
}
lists[header].push(x);
}
});
return lists;
},
patchData() {
const data = {};
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = this.featuredTags.slice();
if (newFeaturedTags.length < 1 && this.selectedTags.length > newFeaturedTags.length) {
const nonFeaturedCategories = this.selectedTags.filter((x) => !newFeaturedTags.includes(x));
interface Props {
project: Project & {
actualProjectType: string;
};
allMembers?: any[];
currentMember?: any;
patchProject?: (data: any) => void;
}
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x));
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = this.selectedTags
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name);
const tags = useTags();
if (
categories.length !== this.project.categories.length ||
categories.some((value) => !this.project.categories.includes(value))
) {
data.categories = categories;
}
const props = withDefaults(defineProps<Props>(), {
allMembers: () => [],
currentMember: null,
patchProject: () => {
addNotification({
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
if (
additionalCategories.length !== this.project.additional_categories.length ||
additionalCategories.some((value) => !this.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories;
}
return data;
},
hasChanges() {
return Object.keys(this.patchData).length > 0;
},
},
methods: {
formatProjectType,
formatCategoryHeader,
formatCategory,
toggleCategory(category) {
if (this.selectedTags.includes(category)) {
this.selectedTags = this.selectedTags.filter((x) => x !== category);
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
}
} else {
this.selectedTags.push(category);
}
},
toggleFeaturedCategory(category) {
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
} else {
this.featuredTags.push(category);
}
},
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
},
});
const selectedTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
(props.project.categories.includes(x.name) ||
props.project.additional_categories.includes(x.name)),
),
);
const featuredTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
props.project.categories.includes(x.name),
),
);
const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {};
sortedCategories(tags.value).forEach((x: Category) => {
if (x.project_type === props.project.actualProjectType) {
const header = x.header;
if (!lists[header]) {
lists[header] = [];
}
lists[header].push(x);
}
});
return lists;
});
const tooManyTagsWarning = computed(() => {
const tagCount = selectedTags.value.length;
if (tagCount > 5) {
return `You've selected ${tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.`;
}
return null;
});
const multipleResolutionTagsWarning = computed(() => {
if (props.project.project_type !== "resourcepack") return null;
const resolutionTags = selectedTags.value.filter((tag) =>
["16x", "32x", "48x", "64x", "128x", "256x", "512x", "1024x"].includes(tag.name),
);
if (resolutionTags.length > 1) {
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags.map((t) => t.name).join(", ")}). Resource packs should typically only have one resolution tag.`;
}
return null;
});
const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === props.project.actualProjectType,
);
const totalSelectedTags = selectedTags.value.length;
if (
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
) {
return `You've selected all ${categoriesForProjectType.length} available tags. Please select only the tags that truly apply to your project.`;
}
return null;
});
const patchData = computed(() => {
const data: Record<string, string[]> = {};
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = featuredTags.value.slice();
if (newFeaturedTags.length < 1 && selectedTags.value.length > newFeaturedTags.length) {
const nonFeaturedCategories = selectedTags.value.filter((x) => !newFeaturedTags.includes(x));
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x));
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = selectedTags.value
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name);
if (
categories.length !== props.project.categories.length ||
categories.some((value) => !props.project.categories.includes(value))
) {
data.categories = categories;
}
if (
additionalCategories.length !== props.project.additional_categories.length ||
additionalCategories.some((value) => !props.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories;
}
return data;
});
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0;
});
const toggleCategory = (category: Category) => {
if (selectedTags.value.includes(category)) {
selectedTags.value = selectedTags.value.filter((x) => x !== category);
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
}
} else {
selectedTags.value.push(category);
}
};
const toggleFeaturedCategory = (category: Category) => {
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
} else {
featuredTags.value.push(category);
}
};
const saveChanges = () => {
if (hasChanges.value) {
props.patchProject(patchData.value);
}
};
</script>
<style lang="scss" scoped>
.label__title {
display: flex;