You've already forked AstralRinth
forked from didirus/AstralRinth
Author Validation Improvements (#3970)
* feat: set up typed nag (validators) system * feat: start on frontend impl * fix: shouldShow issues * feat: continue work * feat: re add submitting/re-submit nags * feat: start work implementing validation checks using new nag system * fix: links page + add more validations * feat: tags validations * fix: lint issues * fix: lint * fix: issues * feat: start on i18nifying nags * feat: impl intl * fix: minecraft title clause update --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,10 @@
|
||||
"
|
||||
: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"
|
||||
@@ -38,7 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { SaveIcon } from "@modrinth/assets";
|
||||
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
|
||||
import { MIN_DESCRIPTION_CHARS } from "@modrinth/moderation";
|
||||
import { MarkdownEditor } from "@modrinth/ui";
|
||||
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
|
||||
import { computed, ref } from "vue";
|
||||
@@ -53,6 +58,17 @@ 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;
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
<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"
|
||||
@@ -240,9 +244,18 @@
|
||||
|
||||
<script setup>
|
||||
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
|
||||
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
|
||||
import {
|
||||
UploadIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
IssuesIcon,
|
||||
CheckIcon,
|
||||
TriangleAlertIcon,
|
||||
} 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({
|
||||
@@ -300,6 +313,17 @@ 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(() => {
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
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"
|
||||
@@ -26,11 +31,16 @@
|
||||
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"
|
||||
@@ -61,9 +71,14 @@
|
||||
</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"
|
||||
@@ -123,7 +138,8 @@
|
||||
|
||||
<script setup>
|
||||
import { DropdownSelect } from "@modrinth/ui";
|
||||
import { SaveIcon } from "@modrinth/assets";
|
||||
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
|
||||
import { isCommonUrl, commonLinkDomains } from "@modrinth/moderation";
|
||||
|
||||
const tags = useTags();
|
||||
|
||||
@@ -153,6 +169,21 @@ 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,
|
||||
|
||||
@@ -6,11 +6,31 @@
|
||||
<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>
|
||||
@@ -112,145 +132,181 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { StarIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { formatCategory, formatCategoryHeader, formatProjectType } from "@modrinth/utils";
|
||||
<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";
|
||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||
|
||||
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 Category {
|
||||
name: string;
|
||||
header: string;
|
||||
icon?: string;
|
||||
project_type: string;
|
||||
}
|
||||
|
||||
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);
|
||||
interface Props {
|
||||
project: Project & {
|
||||
actualProjectType: string;
|
||||
};
|
||||
allMembers?: any[];
|
||||
currentMember?: any;
|
||||
patchProject?: (data: any) => void;
|
||||
}
|
||||
|
||||
if (
|
||||
categories.length !== this.project.categories.length ||
|
||||
categories.some((value) => !this.project.categories.includes(value))
|
||||
) {
|
||||
data.categories = categories;
|
||||
}
|
||||
const tags = useTags();
|
||||
|
||||
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 props = withDefaults(defineProps<Props>(), {
|
||||
allMembers: () => [],
|
||||
currentMember: null,
|
||||
patchProject: () => {
|
||||
addNotification({
|
||||
title: "An error occurred",
|
||||
text: "Patch project function not found",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user