Redesign report form and prevent duplicate reports (#3211)
* Redesign report form and prevent duplicate reports * Fix lint * Add malware evidence notice to report form * Fix lint
This commit is contained in:
parent
9574e8e639
commit
a8630e93bc
@ -20,6 +20,9 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"instance.filter.disabled": {
|
||||
"message": "Disabled projects"
|
||||
},
|
||||
"instance.filter.updates-available": {
|
||||
"message": "Updates available"
|
||||
},
|
||||
|
||||
@ -485,6 +485,81 @@
|
||||
"project.versions.title": {
|
||||
"message": "Versions"
|
||||
},
|
||||
"report.already-reported": {
|
||||
"message": "You've already reported {title}"
|
||||
},
|
||||
"report.already-reported-description": {
|
||||
"message": "You have an open report for this {item} already. You can add more details to your report if you have more information to add."
|
||||
},
|
||||
"report.back-to-item": {
|
||||
"message": "Back to {item}"
|
||||
},
|
||||
"report.body.description": {
|
||||
"message": "Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored."
|
||||
},
|
||||
"report.body.title": {
|
||||
"message": "Please provide additional context about your report"
|
||||
},
|
||||
"report.checking": {
|
||||
"message": "Checking {item}..."
|
||||
},
|
||||
"report.could-not-find": {
|
||||
"message": "Could not find {item}"
|
||||
},
|
||||
"report.for.violation": {
|
||||
"message": "Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>"
|
||||
},
|
||||
"report.for.violation.description": {
|
||||
"message": "Examples include malicious, spam, offensive, deceptive, misleading, and illegal content."
|
||||
},
|
||||
"report.form-not-for": {
|
||||
"message": "This form is not for:"
|
||||
},
|
||||
"report.go-to-report": {
|
||||
"message": "Go to report"
|
||||
},
|
||||
"report.not-for.bug-reports": {
|
||||
"message": "Bug reports"
|
||||
},
|
||||
"report.not-for.dmca": {
|
||||
"message": "DMCA takedowns"
|
||||
},
|
||||
"report.not-for.dmca.description": {
|
||||
"message": "See our <policy-link>Copyright Policy</policy-link>."
|
||||
},
|
||||
"report.note.copyright.1": {
|
||||
"message": "Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content."
|
||||
},
|
||||
"report.note.copyright.2": {
|
||||
"message": "If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>."
|
||||
},
|
||||
"report.note.malicious.1": {
|
||||
"message": "Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples."
|
||||
},
|
||||
"report.note.malicious.2": {
|
||||
"message": "Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted."
|
||||
},
|
||||
"report.please-report": {
|
||||
"message": "Please report:"
|
||||
},
|
||||
"report.question.content-id": {
|
||||
"message": "What is the ID of the {item}?"
|
||||
},
|
||||
"report.question.content-type": {
|
||||
"message": "What type of content are you reporting?"
|
||||
},
|
||||
"report.question.report-reason": {
|
||||
"message": "Which of Modrinth's rules is this {item} violating?"
|
||||
},
|
||||
"report.report-content": {
|
||||
"message": "Report content to moderators"
|
||||
},
|
||||
"report.report-item": {
|
||||
"message": "Report {title} to moderators"
|
||||
},
|
||||
"report.submit": {
|
||||
"message": "Submit report"
|
||||
},
|
||||
"revenue.transfers.total": {
|
||||
"message": "You have withdrawn {amount} in total."
|
||||
},
|
||||
|
||||
@ -631,7 +631,7 @@
|
||||
auth.user ? reportProject(project.id) : navigateTo('/auth/sign-in'),
|
||||
color: 'red',
|
||||
hoverOnly: true,
|
||||
shown: !currentMember,
|
||||
shown: !isMember,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
]"
|
||||
@ -1204,6 +1204,10 @@ const members = computed(() => {
|
||||
return owner ? [owner, ...rest] : rest;
|
||||
});
|
||||
|
||||
const isMember = computed(
|
||||
() => auth.value.user && allMembers.value.some((x) => x.user.id === auth.value.user.id),
|
||||
);
|
||||
|
||||
const currentMember = computed(() => {
|
||||
let val = auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null;
|
||||
|
||||
|
||||
@ -1,99 +1,256 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Card>
|
||||
<div class="content">
|
||||
<div>
|
||||
<h1 class="card-title-adjustments">Submit a Report</h1>
|
||||
<div>
|
||||
<p>
|
||||
Modding should be safe for everyone, so we take abuse and malicious intent seriously
|
||||
at Modrinth. If you encounter content that violates our
|
||||
<nuxt-link class="text-link" to="/legal/terms">Terms of Service</nuxt-link> or our
|
||||
<nuxt-link class="text-link" to="/legal/rules">Rules</nuxt-link>, please report it to
|
||||
us here.
|
||||
</p>
|
||||
<p>
|
||||
This form is intended exclusively for reporting abuse or harmful content to Modrinth
|
||||
staff. For bugs related to specific projects, please use the project's designated
|
||||
Issues link or Discord channel.
|
||||
</p>
|
||||
<p>
|
||||
Your privacy is important to us; rest assured that your identifying information will
|
||||
be kept confidential.
|
||||
</p>
|
||||
<div class="experimental-styles-within flex flex-col gap-2">
|
||||
<RadialHeader class="top-box mb-2 text-center" color="orange">
|
||||
<ScaleIcon class="h-12 w-12 text-brand-orange" />
|
||||
<h1 class="m-3 gap-2 text-3xl font-extrabold">
|
||||
{{
|
||||
prefilled && itemName
|
||||
? existingReport
|
||||
? formatMessage(messages.alreadyReportedItem, { title: itemName })
|
||||
: formatMessage(messages.reportItem, { title: itemName })
|
||||
: formatMessage(messages.reportContent)
|
||||
}}
|
||||
</h1>
|
||||
</RadialHeader>
|
||||
<div
|
||||
v-if="prefilled && itemName && existingReport"
|
||||
class="mx-auto flex max-w-[35rem] flex-col items-center gap-4 text-center"
|
||||
>
|
||||
{{ formatMessage(messages.alreadyReportedDescription, { item: reportItem || "content" }) }}
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled v-if="itemLink">
|
||||
<nuxt-link :to="itemLink">
|
||||
<LeftArrowIcon />
|
||||
{{ formatMessage(messages.backToItem, { item: reportItem || "content" }) }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link :to="`/dashboard/report/${existingReport.id}`">
|
||||
{{ formatMessage(messages.goToReport) }} <RightArrowIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-info-section">
|
||||
<div class="report-info-item">
|
||||
<label for="report-item">Item type to report</label>
|
||||
<DropdownSelect
|
||||
id="report-item"
|
||||
<template v-else>
|
||||
<div class="mb-3 grid grid-cols-1 gap-4 px-6 md:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.pleaseReport) }}</h2>
|
||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<CheckCircleIcon class="h-8 w-8 shrink-0 text-brand-green" />
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
<IntlFormatted :message-id="messages.violation">
|
||||
<template #rules-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/rules`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<template #terms-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/terms`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-secondary">
|
||||
{{ formatMessage(messages.violationDescription) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.formNotFor) }}</h2>
|
||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
||||
<span>{{ formatMessage(messages.bugReports) }}</span>
|
||||
</div>
|
||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
||||
<div class="flex flex-col">
|
||||
<span>{{ formatMessage(messages.dmcaTakedown) }}</span>
|
||||
<span class="text-sm font-medium text-secondary">
|
||||
<IntlFormatted :message-id="messages.dmcaTakedownDescription">
|
||||
<template #policy-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/copyright`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="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">
|
||||
{{ formatMessage(messages.whatContentType) }}
|
||||
</span>
|
||||
<RadioButtons
|
||||
v-slot="{ item }"
|
||||
v-model="reportItem"
|
||||
name="report-item"
|
||||
:options="reportItems"
|
||||
:display-name="capitalizeString"
|
||||
:multiple="false"
|
||||
:searchable="false"
|
||||
:show-no-results="false"
|
||||
:show-labels="false"
|
||||
placeholder="Choose report item"
|
||||
/>
|
||||
:items="reportItems"
|
||||
@update:model-value="
|
||||
() => {
|
||||
prefilled = false;
|
||||
fetchItem();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ capitalizeString(item) }}
|
||||
</RadioButtons>
|
||||
</div>
|
||||
<div class="report-info-item">
|
||||
<label for="report-item-id">Item ID</label>
|
||||
<div class="flex flex-col gap-2" :class="{ hidden: !reportItem }">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.whatContentId, { item: reportItem || "content" }) }}
|
||||
</span>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
id="report-item-id"
|
||||
v-model="reportItemID"
|
||||
type="text"
|
||||
placeholder="ex. project ID"
|
||||
placeholder="ex: Dc7EYhxG"
|
||||
autocomplete="off"
|
||||
:disabled="reportItem === ''"
|
||||
class="w-40"
|
||||
@blur="
|
||||
() => {
|
||||
prefilled = false;
|
||||
reportItemID = reportItemID.trim();
|
||||
fetchItem();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="report-info-item">
|
||||
<label for="report-type">Reason for report</label>
|
||||
<DropdownSelect
|
||||
id="report-type"
|
||||
v-model="reportType"
|
||||
name="report-type"
|
||||
:options="reportTypes"
|
||||
:multiple="false"
|
||||
:searchable="false"
|
||||
:show-no-results="false"
|
||||
:show-labels="false"
|
||||
:display-name="capitalizeString"
|
||||
placeholder="Choose report type"
|
||||
<div v-if="checkingId || checkedId" class="flex items-center gap-1">
|
||||
<template v-if="checkingId">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.checking, { item: reportItem }) }}...
|
||||
</template>
|
||||
<template v-else-if="checkedId && itemName">
|
||||
<AutoLink
|
||||
:to="itemLink"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 font-bold text-contrast hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
v-if="typeof itemIcon === 'string'"
|
||||
:src="itemIcon"
|
||||
:alt="itemName"
|
||||
size="24px"
|
||||
:circle="reportItem === 'user'"
|
||||
/>
|
||||
<component :is="itemIcon" v-else-if="itemIcon" />
|
||||
<span>{{ itemName }}</span>
|
||||
</AutoLink>
|
||||
<CheckIcon class="text-brand-green" />
|
||||
</template>
|
||||
<span v-else-if="checkedId" class="contents text-brand-red">
|
||||
<IssuesIcon />
|
||||
{{ formatMessage(messages.couldNotFind, { item: reportItem }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-submission-section">
|
||||
<div>
|
||||
<p>
|
||||
Please provide additional context about your report. Include links and images if
|
||||
possible. <strong>Empty reports will be closed.</strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="existingReport">
|
||||
{{
|
||||
formatMessage(messages.alreadyReportedDescription, { item: reportItem || "content" })
|
||||
}}
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link :to="`/dashboard/report/${existingReport.id}`" class="w-fit">
|
||||
{{ formatMessage(messages.goToReport) }} <RightArrowIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2" :class="{ hidden: !reportItemID }">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.whatReportReason, { item: reportItem || "content" }) }}
|
||||
</span>
|
||||
<RadioButtons v-slot="{ item }" v-model="reportType" :items="reportTypes">
|
||||
{{ item === "copyright" ? "Reuploaded work" : capitalizeString(item) }}
|
||||
</RadioButtons>
|
||||
</div>
|
||||
<div
|
||||
v-if="warnings[reportType]"
|
||||
class="flex gap-2 rounded-xl border-2 border-solid border-brand-orange bg-highlight-orange p-4 text-contrast"
|
||||
>
|
||||
<IssuesIcon class="h-5 w-5 shrink-0 text-orange" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<p
|
||||
v-for="(warning, index) in warnings[reportType]"
|
||||
:key="`warning-${reportType}-${index}`"
|
||||
class="m-0 leading-tight"
|
||||
>
|
||||
<IntlFormatted :message-id="warning">
|
||||
<template #copyright-policy-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="`/legal/copyright`">
|
||||
<component :is="() => children" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
<MarkdownEditor v-model="reportBody" placeholder="" :on-image-upload="onImageUpload" />
|
||||
</div>
|
||||
<div class="submit-button">
|
||||
<Button
|
||||
<div :class="{ hidden: !reportType }">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ formatMessage(messages.reportBodyTitle) }}
|
||||
</span>
|
||||
<p class="m-0 leading-tight text-secondary">
|
||||
{{ formatMessage(messages.reportBodyDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="{ hidden: !reportType }">
|
||||
<MarkdownEditor
|
||||
v-model="reportBody"
|
||||
placeholder=""
|
||||
:on-image-upload="onImageUpload"
|
||||
/>
|
||||
</div>
|
||||
<div :class="{ hidden: !reportType }">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
id="submit-button"
|
||||
color="primary"
|
||||
:disabled="submitLoading || !canSubmit"
|
||||
@click="submitReport"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
Submit
|
||||
</Button>
|
||||
<SendIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.submitReport) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, Button, MarkdownEditor, DropdownSelect } from "@modrinth/ui";
|
||||
import { SaveIcon } from "@modrinth/assets";
|
||||
import {
|
||||
MarkdownEditor,
|
||||
RadialHeader,
|
||||
RadioButtons,
|
||||
ButtonStyled,
|
||||
Avatar,
|
||||
AutoLink,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
CheckIcon,
|
||||
SpinnerIcon,
|
||||
SendIcon,
|
||||
IssuesIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ScaleIcon,
|
||||
VersionIcon,
|
||||
} from "@modrinth/assets";
|
||||
import type { User, Version, Report } from "@modrinth/utils";
|
||||
import { useVIntl, defineMessages, type MessageDescriptor } from "@vintl/vintl";
|
||||
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||
|
||||
const tags = useTags();
|
||||
@ -101,6 +258,7 @@ const route = useNativeRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const auth = await useAuth();
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
if (!auth.value.user) {
|
||||
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
|
||||
@ -119,6 +277,80 @@ const reportItem = ref<string>(accessQuery("item"));
|
||||
const reportItemID = ref<string>(accessQuery("itemID"));
|
||||
const reportType = ref<string>("");
|
||||
|
||||
const prefilled = ref<boolean>(!!reportItem.value && !!reportItemID.value);
|
||||
const checkedId = ref<boolean>(false);
|
||||
const checkingId = ref<boolean>(false);
|
||||
|
||||
const currentProject = ref<Project | null>(null);
|
||||
const currentVersion = ref<Version | null>(null);
|
||||
const currentUser = ref<User | null>(null);
|
||||
|
||||
const itemIcon = ref<string | Component | undefined>();
|
||||
const itemName = ref<string | undefined>();
|
||||
const itemLink = ref<string | undefined>();
|
||||
const itemId = ref<string | undefined>();
|
||||
|
||||
const reports = ref<Report[]>([]);
|
||||
const existingReport = computed(() =>
|
||||
reports.value.find(
|
||||
(x) =>
|
||||
(x.item_id === reportItemID.value || x.item_id === itemId.value) &&
|
||||
x.item_type === reportItem.value,
|
||||
),
|
||||
);
|
||||
|
||||
await fetchItem();
|
||||
await fetchExistingReports();
|
||||
|
||||
const currentItemValid = computed(
|
||||
() => !!currentProject.value || !!currentVersion.value || !!currentUser.value,
|
||||
);
|
||||
|
||||
async function fetchExistingReports() {
|
||||
reports.value = ((await useBaseFetch("report?count=1000")) as Report[]).filter(
|
||||
(x) => x.reporter === auth.value.user?.id,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchItem() {
|
||||
if (reportItem.value && reportItemID.value) {
|
||||
checkingId.value = true;
|
||||
itemIcon.value = undefined;
|
||||
itemName.value = undefined;
|
||||
itemLink.value = undefined;
|
||||
itemId.value = undefined;
|
||||
try {
|
||||
if (reportItem.value === "project") {
|
||||
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project;
|
||||
currentProject.value = project;
|
||||
|
||||
itemIcon.value = project.icon_url;
|
||||
itemName.value = project.title;
|
||||
itemLink.value = `/project/${project.id}`;
|
||||
itemId.value = project.id;
|
||||
} else if (reportItem.value === "version") {
|
||||
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version;
|
||||
currentVersion.value = version;
|
||||
|
||||
itemIcon.value = VersionIcon;
|
||||
itemName.value = version.version_number;
|
||||
itemLink.value = `project/${version.project_id}/version/${version.id}`;
|
||||
itemId.value = version.id;
|
||||
} else if (reportItem.value === "user") {
|
||||
const user = (await useBaseFetch(`user/${reportItemID.value}`)) as User;
|
||||
currentUser.value = user;
|
||||
|
||||
itemIcon.value = user.avatar_url;
|
||||
itemName.value = user.username;
|
||||
itemLink.value = `/user/${user.username}`;
|
||||
itemId.value = user.id;
|
||||
}
|
||||
} catch {}
|
||||
checkedId.value = true;
|
||||
checkingId.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const reportItems = ["project", "version", "user"];
|
||||
const reportTypes = computed(() => tags.value.reportTypes);
|
||||
|
||||
@ -232,70 +464,131 @@ const onImageUpload = async (file: File) => {
|
||||
uploadedImageIDs.value.push(item.id);
|
||||
return item.url;
|
||||
};
|
||||
|
||||
const warnings: Record<string, MessageDescriptor[]> = {
|
||||
copyright: [
|
||||
defineMessage({
|
||||
id: "report.note.copyright.1",
|
||||
defaultMessage:
|
||||
"Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content.",
|
||||
}),
|
||||
defineMessage({
|
||||
id: "report.note.copyright.2",
|
||||
defaultMessage:
|
||||
"If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>.",
|
||||
}),
|
||||
],
|
||||
malicious: [
|
||||
defineMessage({
|
||||
id: "report.note.malicious.1",
|
||||
defaultMessage:
|
||||
"Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples.",
|
||||
}),
|
||||
defineMessage({
|
||||
id: "report.note.malicious.2",
|
||||
defaultMessage:
|
||||
"Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted.",
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
reportContent: {
|
||||
id: "report.report-content",
|
||||
defaultMessage: "Report content to moderators",
|
||||
},
|
||||
reportItem: {
|
||||
id: "report.report-item",
|
||||
defaultMessage: "Report {title} to moderators",
|
||||
},
|
||||
alreadyReportedItem: {
|
||||
id: "report.already-reported",
|
||||
defaultMessage: "You've already reported {title}",
|
||||
},
|
||||
alreadyReportedDescription: {
|
||||
id: "report.already-reported-description",
|
||||
defaultMessage:
|
||||
"You have an open report for this {item} already. You can add more details to your report if you have more information to add.",
|
||||
},
|
||||
backToItem: {
|
||||
id: "report.back-to-item",
|
||||
defaultMessage: "Back to {item}",
|
||||
},
|
||||
goToReport: {
|
||||
id: "report.go-to-report",
|
||||
defaultMessage: "Go to report",
|
||||
},
|
||||
pleaseReport: {
|
||||
id: "report.please-report",
|
||||
defaultMessage: "Please report:",
|
||||
},
|
||||
formNotFor: {
|
||||
id: "report.form-not-for",
|
||||
defaultMessage: "This form is not for:",
|
||||
},
|
||||
violation: {
|
||||
id: "report.for.violation",
|
||||
defaultMessage:
|
||||
"Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>",
|
||||
},
|
||||
violationDescription: {
|
||||
id: "report.for.violation.description",
|
||||
defaultMessage:
|
||||
"Examples include malicious, spam, offensive, deceptive, misleading, and illegal content.",
|
||||
},
|
||||
bugReports: {
|
||||
id: "report.not-for.bug-reports",
|
||||
defaultMessage: "Bug reports",
|
||||
},
|
||||
dmcaTakedown: {
|
||||
id: "report.not-for.dmca",
|
||||
defaultMessage: "DMCA takedowns",
|
||||
},
|
||||
dmcaTakedownDescription: {
|
||||
id: "report.not-for.dmca.description",
|
||||
defaultMessage: "See our <policy-link>Copyright Policy</policy-link>.",
|
||||
},
|
||||
whatContentType: {
|
||||
id: "report.question.content-type",
|
||||
defaultMessage: "What type of content are you reporting?",
|
||||
},
|
||||
whatContentId: {
|
||||
id: "report.question.content-id",
|
||||
defaultMessage: "What is the ID of the {item}?",
|
||||
},
|
||||
whatReportReason: {
|
||||
id: "report.question.report-reason",
|
||||
defaultMessage: "Which of Modrinth's rules is this {item} violating?",
|
||||
},
|
||||
checking: {
|
||||
id: "report.checking",
|
||||
defaultMessage: "Checking {item}...",
|
||||
},
|
||||
couldNotFind: {
|
||||
id: "report.could-not-find",
|
||||
defaultMessage: "Could not find {item}",
|
||||
},
|
||||
reportBodyTitle: {
|
||||
id: "report.body.title",
|
||||
defaultMessage: "Please provide additional context about your report",
|
||||
},
|
||||
reportBodyDescription: {
|
||||
id: "report.body.description",
|
||||
defaultMessage:
|
||||
"Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored.",
|
||||
},
|
||||
submitReport: {
|
||||
id: "report.submit",
|
||||
defaultMessage: "Submit report",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.submit-button {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.card-title-adjustments {
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 0.5rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
// TODO: Get rid of this hack when removing global styles from the website.
|
||||
// Overflow decides the behavior of md editor but also clips the border.
|
||||
// In the future, we should use ring instead of block-shadow for the
|
||||
// green ring around the md editor
|
||||
padding-inline: var(--gap-md);
|
||||
padding-bottom: var(--gap-md);
|
||||
margin-inline: calc(var(--gap-md) * -1);
|
||||
|
||||
display: grid;
|
||||
|
||||
// Disable horizontal stretch
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-info-section {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
gap: var(--gap-md);
|
||||
|
||||
:global(.animated-dropdown) {
|
||||
& > .selected {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.report-info-item {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--gap-sm);
|
||||
color: var(--color-text-dark);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -300,8 +300,8 @@ import Chips from './Chips.vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
disabled: boolean
|
||||
headingButtons: boolean
|
||||
disabled?: boolean
|
||||
headingButtons?: boolean
|
||||
/**
|
||||
* @param file The file to upload
|
||||
* @throws If the file is invalid or the upload fails
|
||||
@ -948,4 +948,8 @@ function openVideoModal() {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:deep(.cm-content) {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
49
packages/ui/src/components/base/RadialHeader.vue
Normal file
49
packages/ui/src/components/base/RadialHeader.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div :style="colorClasses" class="radial-header relative pb-1" v-bind="$attrs">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="radial-header-divider" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
|
||||
}>(),
|
||||
{
|
||||
color: 'brand',
|
||||
},
|
||||
)
|
||||
|
||||
const colorClasses = computed(
|
||||
() =>
|
||||
`--_radial-bg: var(--color-${props.color}-highlight);--_radial-border: var(--color-${props.color});`,
|
||||
)
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.radial-header {
|
||||
background-image: radial-gradient(50% 100% at 50% 100%, var(--_radial-bg) 10%, #ffffff00 100%);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ffffff00 0%,
|
||||
var(--_radial-border) 50%,
|
||||
#ffffff00 100%
|
||||
);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
packages/ui/src/components/base/RadioButtons.vue
Normal file
48
packages/ui/src/components/base/RadioButtons.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="`radio-button-${index}`"
|
||||
class="p-0 py-2 px-2 border-0 flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
|
||||
:class="{
|
||||
'text-contrast font-medium bg-button-bg': selected === item,
|
||||
'text-primary bg-transparent': selected !== item,
|
||||
}"
|
||||
@click="selected = item"
|
||||
>
|
||||
<RadioButtonChecked v-if="selected === item" class="text-brand h-5 w-5" />
|
||||
<RadioButtonIcon v-else class="h-5 w-5" />
|
||||
<slot :item="item" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" generic="T">
|
||||
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
items: T[]
|
||||
forceSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
forceSelection: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selected = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
if (props.items.length > 0 && props.forceSelection && !props.modelValue) {
|
||||
selected.value = props.items[0]
|
||||
}
|
||||
</script>
|
||||
@ -25,6 +25,8 @@ export { default as Pagination } from './base/Pagination.vue'
|
||||
export { default as PopoutMenu } from './base/PopoutMenu.vue'
|
||||
export { default as PreviewSelectButton } from './base/PreviewSelectButton.vue'
|
||||
export { default as ProjectCard } from './base/ProjectCard.vue'
|
||||
export { default as RadialHeader } from './base/RadialHeader.vue'
|
||||
export { default as RadioButtons } from './base/RadioButtons.vue'
|
||||
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
|
||||
export { default as SimpleBadge } from './base/SimpleBadge.vue'
|
||||
export { default as Slider } from './base/Slider.vue'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
export type Base62Char = (typeof BASE62_CHARS)[number]
|
||||
|
||||
export type ModrinthId = `${Base62Char}`[]
|
||||
export type ModrinthId = string
|
||||
|
||||
export type Environment = 'required' | 'optional' | 'unsupported' | 'unknown'
|
||||
|
||||
@ -241,3 +241,15 @@ export interface TeamMember {
|
||||
payouts_split: number
|
||||
ordering: number
|
||||
}
|
||||
|
||||
export type Report = {
|
||||
id: ModrinthId
|
||||
item_id: ModrinthId
|
||||
item_type: 'project' | 'version' | 'user'
|
||||
report_type: string
|
||||
reporter: ModrinthId
|
||||
thread_id: ModrinthId
|
||||
closed: boolean
|
||||
created: string
|
||||
body: string
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user