Merge branch 'main' into cal/dev-124-project-validation
Signed-off-by: IMB11 <hendersoncal117@gmail.com>
This commit is contained in:
commit
f606b20109
@ -59,10 +59,12 @@
|
|||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
"pinia-plugin-persistedstate": "^4.4.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"three": "^0.172.0",
|
"three": "^0.172.0",
|
||||||
|
"vue-confetti-explosion": "^1.0.2",
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-typed-virtual-list": "^1.0.10",
|
"vue-typed-virtual-list": "^1.0.10",
|
||||||
"vue3-ace-editor": "^2.2.4",
|
"vue3-ace-editor": "^2.2.4",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div class="universal-card">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||||
|
<nuxt-link
|
||||||
|
v-if="report.target"
|
||||||
|
:to="`/${report.target.type}/${report.target.slug}`"
|
||||||
|
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="report.target.avatar_url"
|
||||||
|
:circle="report.target.type === 'user'"
|
||||||
|
size="1rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">
|
||||||
|
<OrganizationIcon
|
||||||
|
v-if="report.target.type === 'organization'"
|
||||||
|
class="align-middle"
|
||||||
|
/>
|
||||||
|
{{ report.target.name }}
|
||||||
|
</span>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||||
|
>
|
||||||
|
Score: {{ report.priority_score }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
|
||||||
|
:class="{
|
||||||
|
'text-brand': report.status === 'approved',
|
||||||
|
'text-red': report.status === 'rejected',
|
||||||
|
'text-secondary': report.status === 'pending',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
|
||||||
|
</span>
|
||||||
|
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
|
||||||
|
{{
|
||||||
|
report.version.files.find((file) => file.primary)?.filename ||
|
||||||
|
"Unknown primary file"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
|
||||||
|
>
|
||||||
|
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
|
||||||
|
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled class="flex-1 sm:flex-none">
|
||||||
|
<button
|
||||||
|
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||||
|
:disabled="!isPending"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled class="flex-1 sm:flex-none">
|
||||||
|
<button
|
||||||
|
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||||
|
:disabled="!isPending"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-2 sm:justify-start">
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<nuxt-link :to="versionUrl">
|
||||||
|
<EyeIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<OverflowMenu :options="quickActions">
|
||||||
|
<template #default>
|
||||||
|
<EllipsisVerticalIcon />
|
||||||
|
</template>
|
||||||
|
<template #copy-id>
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
<span class="hidden sm:inline">Copy ID</span>
|
||||||
|
</template>
|
||||||
|
<template #copy-link>
|
||||||
|
<LinkIcon />
|
||||||
|
<span class="hidden sm:inline">Copy link</span>
|
||||||
|
</template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-secondary sm:hidden">
|
||||||
|
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
useRelativeTime,
|
||||||
|
OverflowMenu,
|
||||||
|
type OverflowMenuOption,
|
||||||
|
ButtonStyled,
|
||||||
|
} from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
OrganizationIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
LinkIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import type { ExtendedDelphiReport } from "@modrinth/moderation";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
report: ExtendedDelphiReport;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
const isPending = computed(() => props.report.status === "pending");
|
||||||
|
|
||||||
|
const quickActions: OverflowMenuOption[] = [
|
||||||
|
{
|
||||||
|
id: "copy-link",
|
||||||
|
action: () => {
|
||||||
|
const base = window.location.origin;
|
||||||
|
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
|
||||||
|
navigator.clipboard.writeText(reviewUrl).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Tech review link copied",
|
||||||
|
text: "The link to this tech review has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copy-id",
|
||||||
|
action: () => {
|
||||||
|
navigator.clipboard.writeText(props.report.version.id).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Version ID copied",
|
||||||
|
text: "The ID of this version has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const versionUrl = computed(() => {
|
||||||
|
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<div class="flex-shrink-0 rounded-lg">
|
||||||
|
<Avatar size="48px" :src="queueEntry.project.icon_url" />
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<h3 class="truncate text-lg font-semibold">
|
||||||
|
{{ queueEntry.project.name }}
|
||||||
|
</h3>
|
||||||
|
<nuxt-link
|
||||||
|
v-if="queueEntry.owner"
|
||||||
|
target="_blank"
|
||||||
|
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||||
|
:to="`/user/${queueEntry.owner.user.username}`"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="queueEntry.owner.user.avatar_url"
|
||||||
|
circle
|
||||||
|
size="16px"
|
||||||
|
class="inline-block flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link
|
||||||
|
v-else-if="queueEntry.org"
|
||||||
|
target="_blank"
|
||||||
|
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||||
|
:to="`/organization/${queueEntry.org.slug}`"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="queueEntry.org.icon_url"
|
||||||
|
circle
|
||||||
|
size="16px"
|
||||||
|
class="inline-block flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ queueEntry.org.name }}</span>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
|
||||||
|
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
|
||||||
|
<BoxIcon
|
||||||
|
v-if="queueEntry.project.project_type === 'mod'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PaintbrushIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'resourcepack'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<BracesIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'datapack'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PackageOpenIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'modpack'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<GlassesIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'shader'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PlugIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'plugin'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="hidden sm:inline">{{
|
||||||
|
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
|
||||||
|
}}</span>
|
||||||
|
<span class="sm:hidden">{{
|
||||||
|
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="hidden text-sm sm:inline">•</span>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 text-sm">
|
||||||
|
Requesting
|
||||||
|
<Badge
|
||||||
|
v-if="props.queueEntry.project.requested_status"
|
||||||
|
:type="props.queueEntry.project.requested_status"
|
||||||
|
class="status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="hidden text-sm sm:inline">•</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
|
||||||
|
class="truncate text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-red': daysInQueue > 4,
|
||||||
|
'text-orange': daysInQueue > 2,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
|
||||||
|
<span class="sm:hidden">{{
|
||||||
|
getSubmittedTime(queueEntry).replace("Submitted ", "")
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 sm:justify-start">
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
|
||||||
|
<EyeIcon class="size-4" />
|
||||||
|
</NuxtLink>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular color="orange" @click="openProjectForReview">
|
||||||
|
<button>
|
||||||
|
<ScaleIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
EyeIcon,
|
||||||
|
PaintbrushIcon,
|
||||||
|
ScaleIcon,
|
||||||
|
BoxIcon,
|
||||||
|
GlassesIcon,
|
||||||
|
PlugIcon,
|
||||||
|
PackageOpenIcon,
|
||||||
|
BracesIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
formatProjectType,
|
||||||
|
type Organization,
|
||||||
|
type Project,
|
||||||
|
type TeamMember,
|
||||||
|
} from "@modrinth/utils";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useModerationStore } from "~/store/moderation.ts";
|
||||||
|
import type { ModerationProject } from "~/helpers/moderation";
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
const moderationStore = useModerationStore();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
queueEntry: ModerationProject;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function getDaysQueued(date: Date): number {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedDate = computed(() => {
|
||||||
|
return dayjs(
|
||||||
|
props.queueEntry.project.queued ||
|
||||||
|
props.queueEntry.project.created ||
|
||||||
|
props.queueEntry.project.updated,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const daysInQueue = computed(() => {
|
||||||
|
return getDaysQueued(queuedDate.value.toDate());
|
||||||
|
});
|
||||||
|
|
||||||
|
function openProjectForReview() {
|
||||||
|
moderationStore.setSingleProject(props.queueEntry.project.id);
|
||||||
|
navigateTo({
|
||||||
|
name: "type-id",
|
||||||
|
params: {
|
||||||
|
type: "project",
|
||||||
|
id: props.queueEntry.project.id,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showChecklist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmittedTime(project: any): string {
|
||||||
|
const date =
|
||||||
|
props.queueEntry.project.queued ||
|
||||||
|
props.queueEntry.project.created ||
|
||||||
|
props.queueEntry.project.updated;
|
||||||
|
if (!date) return "Unknown";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
|
||||||
|
} catch {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<div class="universal-card">
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
|
||||||
|
>
|
||||||
|
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
Reported for
|
||||||
|
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
|
||||||
|
{{ formattedReportType }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="hidden sm:inline">By</span>
|
||||||
|
<span class="sm:hidden">Reporter:</span>
|
||||||
|
<nuxt-link
|
||||||
|
:to="`/user/${report.reporter_user.username}`"
|
||||||
|
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="report.reporter_user.avatar_url"
|
||||||
|
circle
|
||||||
|
size="1.75rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ report.reporter_user.username }}</span>
|
||||||
|
</nuxt-link>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
|
||||||
|
<span class="text-md whitespace-nowrap text-secondary">{{
|
||||||
|
formatRelativeTime(report.created)
|
||||||
|
}}</span>
|
||||||
|
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
|
||||||
|
<OverflowMenu :options="visibleQuickReplies">
|
||||||
|
<span class="hidden sm:inline">Quick Reply</span>
|
||||||
|
<span class="sr-only sm:hidden">Quick Reply</span>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<OverflowMenu :options="quickActions">
|
||||||
|
<template #default>
|
||||||
|
<EllipsisVerticalIcon />
|
||||||
|
</template>
|
||||||
|
<template #copy-id>
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
<span class="hidden sm:inline">Copy ID</span>
|
||||||
|
</template>
|
||||||
|
<template #copy-link>
|
||||||
|
<LinkIcon />
|
||||||
|
<span class="hidden sm:inline">Copy link</span>
|
||||||
|
</template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4 rounded-xl border-solid text-divider" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
:src="reportItemAvatarUrl"
|
||||||
|
:circle="report.item_type === 'user'"
|
||||||
|
size="3rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
|
||||||
|
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||||
|
<nuxt-link
|
||||||
|
v-if="report.target && report.item_type != 'user'"
|
||||||
|
:to="`/${report.target.type}/${report.target.slug}`"
|
||||||
|
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="report.target?.avatar_url"
|
||||||
|
:circle="report.target.type === 'user'"
|
||||||
|
size="1rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">
|
||||||
|
<OrganizationIcon
|
||||||
|
v-if="report.target.type === 'organization'"
|
||||||
|
class="align-middle"
|
||||||
|
/>
|
||||||
|
{{ report.target.name || "Unknown User" }}
|
||||||
|
</span>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||||
|
>
|
||||||
|
{{ formattedItemType }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="report.item_type === 'version' && report.version"
|
||||||
|
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end sm:justify-start">
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<nuxt-link :to="reportItemUrl">
|
||||||
|
<EyeIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
|
||||||
|
<ReportThread
|
||||||
|
v-if="report.thread"
|
||||||
|
ref="reportThread"
|
||||||
|
class="mb-16 sm:mb-0"
|
||||||
|
:thread="report.thread"
|
||||||
|
:report="report"
|
||||||
|
:reporter="report.reporter_user"
|
||||||
|
@update-thread="updateThread"
|
||||||
|
/>
|
||||||
|
</CollapsibleRegion>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
useRelativeTime,
|
||||||
|
OverflowMenu,
|
||||||
|
type OverflowMenuOption,
|
||||||
|
CollapsibleRegion,
|
||||||
|
ButtonStyled,
|
||||||
|
} from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
OrganizationIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
LinkIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import {
|
||||||
|
type ExtendedReport,
|
||||||
|
reportQuickReplies,
|
||||||
|
type ReportQuickReply,
|
||||||
|
} from "@modrinth/moderation";
|
||||||
|
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
|
||||||
|
import ReportThread from "../thread/ReportThread.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
report: ExtendedReport;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
|
||||||
|
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
|
||||||
|
function updateThread(newThread: any) {
|
||||||
|
if (props.report.thread) {
|
||||||
|
Object.assign(props.report.thread, newThread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickActions: OverflowMenuOption[] = [
|
||||||
|
{
|
||||||
|
id: "copy-link",
|
||||||
|
action: () => {
|
||||||
|
const base = window.location.origin;
|
||||||
|
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
|
||||||
|
navigator.clipboard.writeText(reportUrl).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Report link copied",
|
||||||
|
text: "The link to this report has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copy-id",
|
||||||
|
action: () => {
|
||||||
|
navigator.clipboard.writeText(props.report.id).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Report ID copied",
|
||||||
|
text: "The ID of this report has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||||
|
return reportQuickReplies
|
||||||
|
.filter((reply) => {
|
||||||
|
if (reply.shouldShow === undefined) return true;
|
||||||
|
if (typeof reply.shouldShow === "function") {
|
||||||
|
return reply.shouldShow(props.report);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.shouldShow;
|
||||||
|
})
|
||||||
|
.map(
|
||||||
|
(reply) =>
|
||||||
|
({
|
||||||
|
id: reply.label,
|
||||||
|
action: () => handleQuickReply(reply),
|
||||||
|
}) as OverflowMenuOption,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleQuickReply(reply: ReportQuickReply) {
|
||||||
|
const message =
|
||||||
|
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
|
||||||
|
|
||||||
|
collapsibleRegion.value?.setCollapsed(false);
|
||||||
|
await nextTick();
|
||||||
|
reportThread.value?.setReplyContent(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportItemAvatarUrl = computed(() => {
|
||||||
|
switch (props.report.item_type) {
|
||||||
|
case "project":
|
||||||
|
case "version":
|
||||||
|
return props.report.project?.icon_url || "";
|
||||||
|
case "user":
|
||||||
|
return props.report.user?.avatar_url || "";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportItemTitle = computed(() => {
|
||||||
|
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
|
||||||
|
|
||||||
|
return props.report.project?.title || "Unknown Project";
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportItemUrl = computed(() => {
|
||||||
|
switch (props.report.item_type) {
|
||||||
|
case "user":
|
||||||
|
return `/user/${props.report.user?.username}`;
|
||||||
|
case "project":
|
||||||
|
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
|
||||||
|
case "version":
|
||||||
|
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedItemType = computed(() => {
|
||||||
|
const itemType = props.report.item_type;
|
||||||
|
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedReportType = computed(() => {
|
||||||
|
const reportType = props.report.report_type;
|
||||||
|
|
||||||
|
// some are split by -, some are split by " "
|
||||||
|
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
|
||||||
|
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@ -29,7 +29,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue";
|
import { ref } from "vue";
|
||||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMac() {
|
function isMac() {
|
||||||
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
return navigator.platform.toUpperCase().includes("MAC");
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(event?: MouseEvent) {
|
function show(event?: MouseEvent) {
|
||||||
@ -42,9 +42,9 @@
|
|||||||
<div v-if="done">
|
<div v-if="done">
|
||||||
<p>
|
<p>
|
||||||
You are done moderating this project!
|
You are done moderating this project!
|
||||||
<template v-if="futureProjectCount > 0">
|
<template v-if="moderationStore.hasItems">
|
||||||
There are
|
There are
|
||||||
{{ futureProjectCount }} left.
|
{{ moderationStore.queueLength }} left.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +98,7 @@
|
|||||||
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
|
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
|
||||||
<template v-for="action in toggleActions" :key="getActionKey(action)">
|
<template v-for="action in toggleActions" :key="getActionKey(action)">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="actionStates[getActionId(action)]?.selected ?? false"
|
:model-value="isActionSelected(action)"
|
||||||
:label="action.label"
|
:label="action.label"
|
||||||
:description="action.description"
|
:description="action.description"
|
||||||
:disabled="false"
|
:disabled="false"
|
||||||
@ -215,26 +215,26 @@
|
|||||||
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled v-if="!done && !generatedMessage && futureProjectCount > 0">
|
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
|
||||||
<button @click="goToNextProject">
|
<button @click="skipCurrentProject">
|
||||||
<XIcon aria-hidden="true" />
|
<XIcon aria-hidden="true" />
|
||||||
Skip
|
Skip ({{ moderationStore.queueLength }} left)
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div v-if="done">
|
<div v-if="done">
|
||||||
<ButtonStyled v-if="futureProjectCount > 0" color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button @click="goToNextProject">
|
<button @click="endChecklist(undefined)">
|
||||||
<RightArrowIcon aria-hidden="true" />
|
<template v-if="hasNextProject">
|
||||||
Next Project
|
<RightArrowIcon aria-hidden="true" />
|
||||||
</button>
|
Next Project ({{ moderationStore.queueLength }} left)
|
||||||
</ButtonStyled>
|
</template>
|
||||||
<ButtonStyled v-else color="brand">
|
<template v-else>
|
||||||
<button @click="exitModeration">
|
<CheckIcon aria-hidden="true" />
|
||||||
<CheckIcon aria-hidden="true" />
|
All Done!
|
||||||
Done
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@ -370,29 +370,21 @@ import {
|
|||||||
import * as prettier from "prettier";
|
import * as prettier from "prettier";
|
||||||
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
||||||
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
||||||
|
import { useModerationStore } from "~/store/moderation.ts";
|
||||||
|
|
||||||
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<{
|
||||||
defineProps<{
|
project: Project;
|
||||||
project: Project;
|
collapsed: boolean;
|
||||||
futureProjectIds?: string[];
|
}>();
|
||||||
collapsed: boolean;
|
|
||||||
}>(),
|
const moderationStore = useModerationStore();
|
||||||
{
|
|
||||||
futureProjectIds: () => [] as string[],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const variables = computed(() => {
|
const variables = computed(() => {
|
||||||
return flattenProjectVariables(props.project);
|
return flattenProjectVariables(props.project);
|
||||||
});
|
});
|
||||||
|
|
||||||
const futureProjectCount = computed(() => {
|
|
||||||
const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
|
||||||
return ids.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
const modpackPermissionsComplete = ref(false);
|
const modpackPermissionsComplete = ref(false);
|
||||||
const modpackJudgements = ref<ModerationJudgements>({});
|
const modpackJudgements = ref<ModerationJudgements>({});
|
||||||
const isModpackPermissionsStage = computed(() => {
|
const isModpackPermissionsStage = computed(() => {
|
||||||
@ -516,7 +508,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
|||||||
isLoadingMessage: loadingMessage.value,
|
isLoadingMessage: loadingMessage.value,
|
||||||
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
||||||
|
|
||||||
futureProjectCount: futureProjectCount.value,
|
futureProjectCount: moderationStore.queueLength,
|
||||||
visibleActionsCount: visibleActions.value.length,
|
visibleActionsCount: visibleActions.value.length,
|
||||||
|
|
||||||
focusedActionIndex: focusedActionIndex.value,
|
focusedActionIndex: focusedActionIndex.value,
|
||||||
@ -529,7 +521,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
|||||||
tryGoNext: nextStage,
|
tryGoNext: nextStage,
|
||||||
tryGoBack: previousStage,
|
tryGoBack: previousStage,
|
||||||
tryGenerateMessage: generateMessage,
|
tryGenerateMessage: generateMessage,
|
||||||
trySkipProject: goToNextProject,
|
trySkipProject: skipCurrentProject,
|
||||||
|
|
||||||
tryToggleCollapse: () => emit("toggleCollapsed"),
|
tryToggleCollapse: () => emit("toggleCollapsed"),
|
||||||
tryResetProgress: resetProgress,
|
tryResetProgress: resetProgress,
|
||||||
@ -652,12 +644,17 @@ function initializeStageActions(stage: Stage, stageIndex: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActionId(action: Action, index?: number): string {
|
function getActionId(action: Action, index?: number): string {
|
||||||
|
// If index is not provided, find it in the current stage's actions
|
||||||
|
if (index === undefined) {
|
||||||
|
index = currentStageObj.value.actions.indexOf(action);
|
||||||
|
}
|
||||||
return getActionIdForStage(action, currentStage.value, index);
|
return getActionIdForStage(action, currentStage.value, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActionKey(action: Action): string {
|
function getActionKey(action: Action): string {
|
||||||
const index = visibleActions.value.indexOf(action);
|
// Find the actual index of this action in the current stage's actions array
|
||||||
return `${currentStage.value}-${index}-${getActionId(action)}`;
|
const index = currentStageObj.value.actions.indexOf(action);
|
||||||
|
return `${currentStage.value}-${index}-${getActionId(action, index)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleActions = computed(() => {
|
const visibleActions = computed(() => {
|
||||||
@ -727,7 +724,8 @@ const multiSelectActions = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
function getDropdownValue(action: DropdownAction) {
|
function getDropdownValue(action: DropdownAction) {
|
||||||
const actionId = getActionId(action);
|
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||||
|
const actionId = getActionId(action, actionIndex);
|
||||||
const visibleOptions = getVisibleDropdownOptions(action);
|
const visibleOptions = getVisibleDropdownOptions(action);
|
||||||
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
|
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
|
||||||
|
|
||||||
@ -742,12 +740,14 @@ function getDropdownValue(action: DropdownAction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isActionSelected(action: Action): boolean {
|
function isActionSelected(action: Action): boolean {
|
||||||
const actionId = getActionId(action);
|
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||||
|
const actionId = getActionId(action, actionIndex);
|
||||||
return actionStates.value[actionId]?.selected || false;
|
return actionStates.value[actionId]?.selected || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAction(action: Action) {
|
function toggleAction(action: Action) {
|
||||||
const actionId = getActionId(action);
|
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||||
|
const actionId = getActionId(action, actionIndex);
|
||||||
const state = actionStates.value[actionId];
|
const state = actionStates.value[actionId];
|
||||||
if (state) {
|
if (state) {
|
||||||
state.selected = !state.selected;
|
state.selected = !state.selected;
|
||||||
@ -756,7 +756,8 @@ function toggleAction(action: Action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectDropdownOption(action: DropdownAction, selected: any) {
|
function selectDropdownOption(action: DropdownAction, selected: any) {
|
||||||
const actionId = getActionId(action);
|
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||||
|
const actionId = getActionId(action, actionIndex);
|
||||||
const state = actionStates.value[actionId];
|
const state = actionStates.value[actionId];
|
||||||
if (state && selected !== undefined && selected !== null) {
|
if (state && selected !== undefined && selected !== null) {
|
||||||
const optionIndex = action.options.findIndex(
|
const optionIndex = action.options.findIndex(
|
||||||
@ -772,7 +773,8 @@ function selectDropdownOption(action: DropdownAction, selected: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
|
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
|
||||||
const actionId = getActionId(action);
|
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||||
|
const actionId = getActionId(action, actionIndex);
|
||||||
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
|
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
|
||||||
|
|
||||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||||
@ -783,7 +785,8 @@ function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
|
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
|
||||||
const actionId = getActionId(action);
|
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||||
|
const actionId = getActionId(action, actionIndex);
|
||||||
const state = actionStates.value[actionId];
|
const state = actionStates.value[actionId];
|
||||||
if (state && state.value instanceof Set) {
|
if (state && state.value instanceof Set) {
|
||||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||||
@ -1056,7 +1059,7 @@ function nextStage() {
|
|||||||
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
|
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: "Modpack permissions stage unfinished",
|
title: "Modpack permissions stage unfinished",
|
||||||
message: "Please complete the modpack permissions stage before proceeding.",
|
text: "Please complete the modpack permissions stage before proceeding.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1133,7 +1136,7 @@ async function generateMessage() {
|
|||||||
console.error("Error generating message:", error);
|
console.error("Error generating message:", error);
|
||||||
addNotification({
|
addNotification({
|
||||||
title: "Error generating message",
|
title: "Error generating message",
|
||||||
message: "Failed to generate moderation message. Please try again.",
|
text: "Failed to generate moderation message. Please try again.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@ -1161,6 +1164,8 @@ function generateModpackMessage(allFiles: {
|
|||||||
attributeMods.push(file.file_name);
|
attributeMods.push(file.file_name);
|
||||||
} else if (file.status === "no" && file.approved === "no") {
|
} else if (file.status === "no" && file.approved === "no") {
|
||||||
noMods.push(file.file_name);
|
noMods.push(file.file_name);
|
||||||
|
} else if (file.status === "permanent-no") {
|
||||||
|
permanentNoMods.push(file.file_name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1202,6 +1207,7 @@ function generateModpackMessage(allFiles: {
|
|||||||
return issues.join("\n\n");
|
return issues.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasNextProject = ref(false);
|
||||||
async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
||||||
try {
|
try {
|
||||||
await useBaseFetch(`project/${props.project.id}`, {
|
await useBaseFetch(`project/${props.project.id}`, {
|
||||||
@ -1236,55 +1242,73 @@ async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
|||||||
|
|
||||||
done.value = true;
|
done.value = true;
|
||||||
|
|
||||||
// Clear local storage for future reviews
|
hasNextProject.value = await moderationStore.completeCurrentProject(
|
||||||
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
|
props.project.id,
|
||||||
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
|
"completed",
|
||||||
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
|
);
|
||||||
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
|
|
||||||
actionStates.value = {};
|
|
||||||
|
|
||||||
addNotification({
|
|
||||||
title: "Moderation submitted",
|
|
||||||
message: `Project ${status} successfully.`,
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting moderation:", error);
|
console.error("Error submitting moderation:", error);
|
||||||
addNotification({
|
addNotification({
|
||||||
title: "Error submitting moderation",
|
title: "Error submitting moderation",
|
||||||
message: "Failed to submit moderation decision. Please try again.",
|
text: "Failed to submit moderation decision. Please try again.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goToNextProject() {
|
async function endChecklist(status?: string) {
|
||||||
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
clearProjectLocalStorage();
|
||||||
|
|
||||||
if (currentIds.length === 0) {
|
if (!hasNextProject.value) {
|
||||||
await navigateTo("/moderation/review");
|
await navigateTo({
|
||||||
return;
|
name: "moderation",
|
||||||
|
state: {
|
||||||
|
confetti: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (moderationStore.currentQueue.total > 1) {
|
||||||
|
addNotification({
|
||||||
|
title: "Moderation completed",
|
||||||
|
text: `You have completed the moderation queue.`,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
title: "Moderation submitted",
|
||||||
|
text: `Project ${status ?? "completed successfully"}.`,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigateTo({
|
||||||
|
name: "type-id",
|
||||||
|
params: {
|
||||||
|
type: "project",
|
||||||
|
id: moderationStore.getCurrentProjectId(),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showChecklist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextProjectId = currentIds[0];
|
|
||||||
const remainingIds = currentIds.slice(1);
|
|
||||||
|
|
||||||
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
|
|
||||||
|
|
||||||
await router.push({
|
|
||||||
name: "type-id",
|
|
||||||
params: {
|
|
||||||
type: "project",
|
|
||||||
id: nextProjectId,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
showChecklist: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exitModeration() {
|
async function skipCurrentProject() {
|
||||||
await navigateTo("/moderation/review");
|
hasNextProject.value = await moderationStore.completeCurrentProject(props.project.id, "skipped");
|
||||||
|
|
||||||
|
await endChecklist("skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProjectLocalStorage() {
|
||||||
|
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
|
||||||
|
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
|
||||||
|
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
|
||||||
|
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
|
||||||
|
localStorage.removeItem(`moderation-stage-${props.project.slug}`);
|
||||||
|
actionStates.value = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLastVisibleStage = computed(() => {
|
const isLastVisibleStage = computed(() => {
|
||||||
282
apps/frontend/src/components/ui/thread/ReportThread.vue
Normal file
282
apps/frontend/src/components/ui/thread/ReportThread.vue
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
|
||||||
|
Thread ID:
|
||||||
|
<CopyCode :text="thread.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sortedMessages.length > 0"
|
||||||
|
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
|
||||||
|
>
|
||||||
|
<ThreadMessage
|
||||||
|
v-for="message in sortedMessages"
|
||||||
|
:key="'message-' + message.id"
|
||||||
|
:thread="thread"
|
||||||
|
:message="message"
|
||||||
|
:members="members"
|
||||||
|
:report="report"
|
||||||
|
:auth="auth"
|
||||||
|
raised
|
||||||
|
@update-thread="() => updateThreadLocal()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="reportClosed">
|
||||||
|
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||||
|
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="reopenReport()"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon class="size-4" />
|
||||||
|
Reopen Thread
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mt-4">
|
||||||
|
<MarkdownEditor
|
||||||
|
v-model="replyBody"
|
||||||
|
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
||||||
|
:on-image-upload="onUploadImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||||
|
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
:disabled="!replyBody"
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="sendReply()"
|
||||||
|
>
|
||||||
|
<ReplyIcon class="size-4" />
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
:disabled="!replyBody"
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="sendReply()"
|
||||||
|
>
|
||||||
|
<SendIcon class="size-4" />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
:disabled="!replyBody"
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="sendReply(true)"
|
||||||
|
>
|
||||||
|
<ScaleIcon class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Add private note</span>
|
||||||
|
<span class="sm:hidden">Private note</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||||
|
<template v-if="isStaff(auth.user)">
|
||||||
|
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="closeReport(true)"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Close with reply</span>
|
||||||
|
<span class="sm:hidden">Close & reply</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="closeReport()"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon class="size-4" />
|
||||||
|
Close report
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CopyCode, MarkdownEditor, ButtonStyled } from "@modrinth/ui";
|
||||||
|
import { ReplyIcon, SendIcon, CheckCircleIcon, ScaleIcon } from "@modrinth/assets";
|
||||||
|
import type { Thread, Report, User, ThreadMessage as TypeThreadMessage } from "@modrinth/utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ThreadMessage from "./ThreadMessage.vue";
|
||||||
|
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||||
|
import { isStaff } from "~/helpers/users.js";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
thread: Thread;
|
||||||
|
reporter: User;
|
||||||
|
report: Report;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const auth = await useAuth();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updateThread: [thread: Thread];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const flags = useFeatureFlags();
|
||||||
|
|
||||||
|
const members = computed(() => {
|
||||||
|
const membersMap: Record<string, User> = {
|
||||||
|
[props.reporter.id]: props.reporter,
|
||||||
|
};
|
||||||
|
for (const member of props.thread.members) {
|
||||||
|
membersMap[member.id] = member;
|
||||||
|
}
|
||||||
|
return membersMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyBody = ref("");
|
||||||
|
function setReplyContent(content: string) {
|
||||||
|
replyBody.value = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
setReplyContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedMessages = computed(() => {
|
||||||
|
const messages: TypeThreadMessage[] = [
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
author_id: props.reporter.id,
|
||||||
|
body: {
|
||||||
|
type: "text",
|
||||||
|
body: props.report.body || "Report opened.",
|
||||||
|
private: false,
|
||||||
|
replying_to: null,
|
||||||
|
associated_images: [],
|
||||||
|
},
|
||||||
|
created: props.report.created,
|
||||||
|
hide_identity: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (props.thread) {
|
||||||
|
messages.push(
|
||||||
|
...[...props.thread.messages].sort(
|
||||||
|
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateThreadLocal() {
|
||||||
|
const threadId = props.report.thread_id;
|
||||||
|
if (threadId) {
|
||||||
|
try {
|
||||||
|
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread;
|
||||||
|
emit("updateThread", thread);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update thread:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageIDs = ref<string[]>([]);
|
||||||
|
|
||||||
|
async function onUploadImage(file: File) {
|
||||||
|
const response = await useImageUpload(file, { context: "thread_message" });
|
||||||
|
|
||||||
|
imageIDs.value.push(response.id);
|
||||||
|
imageIDs.value = imageIDs.value.slice(-10);
|
||||||
|
|
||||||
|
return response.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply(privateMessage = false) {
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
body: {
|
||||||
|
type: "text",
|
||||||
|
body: replyBody.value,
|
||||||
|
private: privateMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageIDs.value.length > 0) {
|
||||||
|
body.body = {
|
||||||
|
...body.body,
|
||||||
|
uploaded_images: imageIDs.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await useBaseFetch(`thread/${props.thread.id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
replyBody.value = "";
|
||||||
|
await updateThreadLocal();
|
||||||
|
} catch (err: any) {
|
||||||
|
addNotification({
|
||||||
|
title: "Error sending message",
|
||||||
|
text: err.data ? err.data.description : err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const didCloseReport = ref(false);
|
||||||
|
const reportClosed = computed(() => {
|
||||||
|
return didCloseReport.value || (props.report && props.report.closed);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function closeReport(reply = false) {
|
||||||
|
if (reply) {
|
||||||
|
await sendReply();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await useBaseFetch(`report/${props.report.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: {
|
||||||
|
closed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await updateThreadLocal();
|
||||||
|
didCloseReport.value = true;
|
||||||
|
} catch (err: any) {
|
||||||
|
addNotification({
|
||||||
|
title: "Error closing report",
|
||||||
|
text: err.data ? err.data.description : err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenReport() {
|
||||||
|
try {
|
||||||
|
await useBaseFetch(`report/${props.report.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: {
|
||||||
|
closed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await updateThreadLocal();
|
||||||
|
} catch (err: any) {
|
||||||
|
addNotification({
|
||||||
|
title: "Error reopening report",
|
||||||
|
text: err.data ? err.data.description : err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -36,7 +36,7 @@
|
|||||||
v-tooltip="'Modrinth Team'"
|
v-tooltip="'Modrinth Team'"
|
||||||
/>
|
/>
|
||||||
<MicrophoneIcon
|
<MicrophoneIcon
|
||||||
v-if="report && message.author_id === report.reporterUser.id"
|
v-if="report && message.author_id === report.reporter_user?.id"
|
||||||
v-tooltip="'Reporter'"
|
v-tooltip="'Reporter'"
|
||||||
class="reporter-icon"
|
class="reporter-icon"
|
||||||
/>
|
/>
|
||||||
|
|||||||
236
apps/frontend/src/helpers/moderation.ts
Normal file
236
apps/frontend/src/helpers/moderation.ts
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import type { ExtendedReport, OwnershipTarget } from "@modrinth/moderation";
|
||||||
|
import type {
|
||||||
|
Thread,
|
||||||
|
Version,
|
||||||
|
User,
|
||||||
|
Project,
|
||||||
|
TeamMember,
|
||||||
|
Organization,
|
||||||
|
Report,
|
||||||
|
} from "@modrinth/utils";
|
||||||
|
|
||||||
|
export const useModerationCache = () => ({
|
||||||
|
threads: useState<Map<string, Thread>>("moderation-report-cache-threads", () => new Map()),
|
||||||
|
users: useState<Map<string, User>>("moderation-report-cache-users", () => new Map()),
|
||||||
|
projects: useState<Map<string, Project>>("moderation-report-cache-projects", () => new Map()),
|
||||||
|
versions: useState<Map<string, Version>>("moderation-report-cache-versions", () => new Map()),
|
||||||
|
teams: useState<Map<string, TeamMember[]>>("moderation-report-cache-teams", () => new Map()),
|
||||||
|
orgs: useState<Map<string, Organization>>("moderation-report-cache-orgs", () => new Map()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: @AlexTMjugador - backend should do all of these functions.
|
||||||
|
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
|
||||||
|
if (reports.length === 0) return [];
|
||||||
|
|
||||||
|
const cache = useModerationCache();
|
||||||
|
|
||||||
|
const threadIDs = reports
|
||||||
|
.map((r) => r.thread_id)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((id) => !cache.threads.value.has(id));
|
||||||
|
const userIDs = [
|
||||||
|
...reports.filter((r) => r.item_type === "user").map((r) => r.item_id),
|
||||||
|
...reports.map((r) => r.reporter),
|
||||||
|
].filter((id) => !cache.users.value.has(id));
|
||||||
|
const versionIDs = reports
|
||||||
|
.filter((r) => r.item_type === "version")
|
||||||
|
.map((r) => r.item_id)
|
||||||
|
.filter((id) => !cache.versions.value.has(id));
|
||||||
|
const projectIDs = reports
|
||||||
|
.filter((r) => r.item_type === "project")
|
||||||
|
.map((r) => r.item_id)
|
||||||
|
.filter((id) => !cache.projects.value.has(id));
|
||||||
|
|
||||||
|
const [newThreads, newVersions, newUsers] = await Promise.all([
|
||||||
|
threadIDs.length > 0
|
||||||
|
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||||
|
Thread[]
|
||||||
|
>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
versionIDs.length > 0
|
||||||
|
? (fetchSegmented(versionIDs, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||||
|
Version[]
|
||||||
|
>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
[...new Set(userIDs)].length > 0
|
||||||
|
? (fetchSegmented(
|
||||||
|
[...new Set(userIDs)],
|
||||||
|
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
|
||||||
|
) as Promise<User[]>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
newThreads.forEach((t) => cache.threads.value.set(t.id, t));
|
||||||
|
newVersions.forEach((v) => cache.versions.value.set(v.id, v));
|
||||||
|
newUsers.forEach((u) => cache.users.value.set(u.id, u));
|
||||||
|
|
||||||
|
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())];
|
||||||
|
const fullProjectIds = new Set([
|
||||||
|
...projectIDs,
|
||||||
|
...allVersions
|
||||||
|
.filter((v) => versionIDs.includes(v.id))
|
||||||
|
.map((v) => v.project_id)
|
||||||
|
.filter(Boolean),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const uncachedProjectIds = Array.from(fullProjectIds).filter(
|
||||||
|
(id) => !cache.projects.value.has(id),
|
||||||
|
);
|
||||||
|
const newProjects =
|
||||||
|
uncachedProjectIds.length > 0
|
||||||
|
? ((await fetchSegmented(
|
||||||
|
uncachedProjectIds,
|
||||||
|
(ids) => `projects?ids=${asEncodedJsonArray(ids)}`,
|
||||||
|
)) as Project[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
newProjects.forEach((p) => cache.projects.value.set(p.id, p));
|
||||||
|
|
||||||
|
const allProjects = [...newProjects, ...Array.from(cache.projects.value.values())];
|
||||||
|
const teamIds = [...new Set(allProjects.map((p) => p.team).filter(Boolean))].filter(
|
||||||
|
(id) => !cache.teams.value.has(id || "invalid team id"),
|
||||||
|
);
|
||||||
|
const orgIds = [...new Set(allProjects.map((p) => p.organization).filter(Boolean))].filter(
|
||||||
|
(id) => !cache.orgs.value.has(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newTeams, newOrgs] = await Promise.all([
|
||||||
|
teamIds.length > 0
|
||||||
|
? (fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||||
|
TeamMember[][]
|
||||||
|
>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
orgIds.length > 0
|
||||||
|
? (fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||||
|
apiVersion: 3,
|
||||||
|
}) as Promise<Organization[]>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
newTeams.forEach((team) => {
|
||||||
|
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
|
||||||
|
});
|
||||||
|
newOrgs.forEach((org) => cache.orgs.value.set(org.id, org));
|
||||||
|
|
||||||
|
return reports.map((report) => {
|
||||||
|
const thread = cache.threads.value.get(report.thread_id) || ({} as Thread);
|
||||||
|
const version =
|
||||||
|
report.item_type === "version" ? cache.versions.value.get(report.item_id) : undefined;
|
||||||
|
|
||||||
|
const project =
|
||||||
|
report.item_type === "project"
|
||||||
|
? cache.projects.value.get(report.item_id)
|
||||||
|
: report.item_type === "version" && version
|
||||||
|
? cache.projects.value.get(version.project_id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let target: OwnershipTarget | undefined;
|
||||||
|
|
||||||
|
if (report.item_type === "user") {
|
||||||
|
const targetUser = cache.users.value.get(report.item_id);
|
||||||
|
if (targetUser) {
|
||||||
|
target = {
|
||||||
|
name: targetUser.username,
|
||||||
|
slug: targetUser.username,
|
||||||
|
avatar_url: targetUser.avatar_url,
|
||||||
|
type: "user",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (project) {
|
||||||
|
let owner: TeamMember | null = null;
|
||||||
|
let org: Organization | null = null;
|
||||||
|
|
||||||
|
if (project.team) {
|
||||||
|
const teamMembers = cache.teams.value.get(project.team);
|
||||||
|
if (teamMembers) {
|
||||||
|
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.organization) {
|
||||||
|
org = cache.orgs.value.get(project.organization) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
target = {
|
||||||
|
name: org.name,
|
||||||
|
avatar_url: org.icon_url,
|
||||||
|
type: "organization",
|
||||||
|
slug: org.slug,
|
||||||
|
};
|
||||||
|
} else if (owner) {
|
||||||
|
target = {
|
||||||
|
name: owner.user.username,
|
||||||
|
avatar_url: owner.user.avatar_url,
|
||||||
|
type: "user",
|
||||||
|
slug: owner.user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...report,
|
||||||
|
thread,
|
||||||
|
reporter_user: cache.users.value.get(report.reporter) || ({} as User),
|
||||||
|
project,
|
||||||
|
user: report.item_type === "user" ? cache.users.value.get(report.item_id) : undefined,
|
||||||
|
version,
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
|
||||||
|
export interface ModerationProject {
|
||||||
|
project: any;
|
||||||
|
owner: TeamMember | null;
|
||||||
|
org: Organization | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
|
||||||
|
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))];
|
||||||
|
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))];
|
||||||
|
|
||||||
|
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
|
||||||
|
teamIds.length > 0
|
||||||
|
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
orgIds.length > 0
|
||||||
|
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||||
|
apiVersion: 3,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cache = useModerationCache();
|
||||||
|
|
||||||
|
teamsData.forEach((team) => {
|
||||||
|
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
|
||||||
|
});
|
||||||
|
|
||||||
|
orgsData.forEach((org: Organization) => {
|
||||||
|
cache.orgs.value.set(org.id, org);
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects.map((project) => {
|
||||||
|
let owner: TeamMember | null = null;
|
||||||
|
let org: Organization | null = null;
|
||||||
|
|
||||||
|
if (project.team_id) {
|
||||||
|
const teamMembers = cache.teams.value.get(project.team_id);
|
||||||
|
if (teamMembers) {
|
||||||
|
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.organization) {
|
||||||
|
org = cache.orgs.value.get(project.organization) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
owner,
|
||||||
|
org,
|
||||||
|
} as ModerationProject;
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -295,7 +295,7 @@
|
|||||||
{
|
{
|
||||||
id: 'review-projects',
|
id: 'review-projects',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
link: '/moderation/review',
|
link: '/moderation/',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'review-reports',
|
id: 'review-reports',
|
||||||
@ -981,23 +981,6 @@ const userMenuOptions = computed(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (
|
|
||||||
(auth.value && auth.value.user && auth.value.user.role === "moderator") ||
|
|
||||||
auth.value.user.role === "admin"
|
|
||||||
) {
|
|
||||||
options = [
|
|
||||||
...options,
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "moderation",
|
|
||||||
color: "orange",
|
|
||||||
link: "/moderation/review",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
...options,
|
...options,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -476,6 +476,30 @@
|
|||||||
"layout.nav.search": {
|
"layout.nav.search": {
|
||||||
"message": "Search"
|
"message": "Search"
|
||||||
},
|
},
|
||||||
|
"moderation.filter.by": {
|
||||||
|
"message": "Filter by"
|
||||||
|
},
|
||||||
|
"moderation.moderate": {
|
||||||
|
"message": "Moderate"
|
||||||
|
},
|
||||||
|
"moderation.page.projects": {
|
||||||
|
"message": "Projects"
|
||||||
|
},
|
||||||
|
"moderation.page.reports": {
|
||||||
|
"message": "Reports"
|
||||||
|
},
|
||||||
|
"moderation.page.technicalReview": {
|
||||||
|
"message": "Technical Review"
|
||||||
|
},
|
||||||
|
"moderation.search.placeholder": {
|
||||||
|
"message": "Search..."
|
||||||
|
},
|
||||||
|
"moderation.sort.by": {
|
||||||
|
"message": "Sort by"
|
||||||
|
},
|
||||||
|
"moderation.technical.search.placeholder": {
|
||||||
|
"message": "Search tech reviews..."
|
||||||
|
},
|
||||||
"profile.button.billing": {
|
"profile.button.billing": {
|
||||||
"message": "Manage user billing"
|
"message": "Manage user billing"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -689,7 +689,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'moderation-checklist',
|
id: 'moderation-checklist',
|
||||||
action: () => (showModerationChecklist = true),
|
action: () => {
|
||||||
|
moderationStore.setSingleProject(project.id);
|
||||||
|
showModerationChecklist = true;
|
||||||
|
},
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
hoverOnly: true,
|
hoverOnly: true,
|
||||||
shown:
|
shown:
|
||||||
@ -870,19 +873,6 @@
|
|||||||
@delete-version="deleteVersion"
|
@delete-version="deleteVersion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__ultimate-sidebar">
|
|
||||||
<!-- Uncomment this to enable the old moderation checklist. -->
|
|
||||||
<!-- <ModerationChecklist
|
|
||||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
|
||||||
:project="project"
|
|
||||||
:future-projects="futureProjects"
|
|
||||||
:reset-project="resetProject"
|
|
||||||
:collapsed="collapsedModerationChecklist"
|
|
||||||
@exit="showModerationChecklist = false"
|
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
|
||||||
/> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -890,9 +880,8 @@
|
|||||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||||
class="moderation-checklist"
|
class="moderation-checklist"
|
||||||
>
|
>
|
||||||
<NewModerationChecklist
|
<ModerationChecklist
|
||||||
:project="project"
|
:project="project"
|
||||||
:future-project-ids="futureProjectIds"
|
|
||||||
:collapsed="collapsedModerationChecklist"
|
:collapsed="collapsedModerationChecklist"
|
||||||
@exit="showModerationChecklist = false"
|
@exit="showModerationChecklist = false"
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||||
@ -969,11 +958,13 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
|||||||
import { userCollectProject } from "~/composables/user.js";
|
import { userCollectProject } from "~/composables/user.js";
|
||||||
import { reportProject } from "~/utils/report-helpers.ts";
|
import { reportProject } from "~/utils/report-helpers.ts";
|
||||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||||
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
|
||||||
|
import { useModerationStore } from "~/store/moderation.ts";
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
const moderationStore = useModerationStore();
|
||||||
|
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const user = await useUser();
|
const user = await useUser();
|
||||||
@ -1561,12 +1552,6 @@ const showModerationChecklist = useLocalStorage(
|
|||||||
);
|
);
|
||||||
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
||||||
|
|
||||||
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
|
|
||||||
|
|
||||||
watch(futureProjectIds, (newValue) => {
|
|
||||||
console.log("Future project IDs updated:", newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
showModerationChecklist,
|
showModerationChecklist,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
|
|||||||
@ -365,8 +365,10 @@ export default defineNuxtComponent({
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
this.expandedGalleryItem = null;
|
this.expandedGalleryItem = null;
|
||||||
} else if (e.key === "ArrowLeft") {
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.stopPropagation();
|
||||||
this.previousImage();
|
this.previousImage();
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.stopPropagation();
|
||||||
this.nextImage();
|
this.nextImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,7 +218,7 @@ const username = ref("");
|
|||||||
const password = ref("");
|
const password = ref("");
|
||||||
const confirmPassword = ref("");
|
const confirmPassword = ref("");
|
||||||
const token = ref("");
|
const token = ref("");
|
||||||
const subscribe = ref(true);
|
const subscribe = ref(false);
|
||||||
|
|
||||||
async function createAccount() {
|
async function createAccount() {
|
||||||
startLoading();
|
startLoading();
|
||||||
|
|||||||
@ -1,33 +1,84 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="normal-page">
|
<div
|
||||||
<div class="normal-page__sidebar">
|
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||||
<aside class="universal-card">
|
>
|
||||||
<h1>Moderation</h1>
|
<h1>Moderation</h1>
|
||||||
<NavStack>
|
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
|
||||||
<NavStackItem link="/moderation" label="Overview">
|
<div class="mb-4 sm:hidden">
|
||||||
<ModrinthIcon aria-hidden="true" />
|
<Chips
|
||||||
</NavStackItem>
|
v-model="selectedChip"
|
||||||
<NavStackItem link="/moderation/review" label="Review projects">
|
:items="mobileNavOptions"
|
||||||
<ScaleIcon aria-hidden="true" />
|
:never-empty="true"
|
||||||
</NavStackItem>
|
@change="navigateToPage"
|
||||||
<NavStackItem link="/moderation/reports" label="Reports">
|
/>
|
||||||
<ReportIcon aria-hidden="true" />
|
|
||||||
</NavStackItem>
|
|
||||||
</NavStack>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
<div class="normal-page__content">
|
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
</div>
|
||||||
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
|
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||||
import NavStack from "~/components/ui/NavStack.vue";
|
import { Chips } from "@modrinth/ui";
|
||||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
import NavTabs from "@/components/ui/NavTabs.vue";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: "auth",
|
middleware: "auth",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
projectsTitle: {
|
||||||
|
id: "moderation.page.projects",
|
||||||
|
defaultMessage: "Projects",
|
||||||
|
},
|
||||||
|
technicalReviewTitle: {
|
||||||
|
id: "moderation.page.technicalReview",
|
||||||
|
defaultMessage: "Technical Review",
|
||||||
|
},
|
||||||
|
reportsTitle: {
|
||||||
|
id: "moderation.page.reports",
|
||||||
|
defaultMessage: "Reports",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moderationLinks = [
|
||||||
|
{ label: formatMessage(messages.projectsTitle), href: "/moderation" },
|
||||||
|
{ label: formatMessage(messages.technicalReviewTitle), href: "/moderation/technical-review" },
|
||||||
|
{ label: formatMessage(messages.reportsTitle), href: "/moderation/reports" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mobileNavOptions = [
|
||||||
|
formatMessage(messages.projectsTitle),
|
||||||
|
formatMessage(messages.technicalReviewTitle),
|
||||||
|
formatMessage(messages.reportsTitle),
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedChip = computed({
|
||||||
|
get() {
|
||||||
|
const path = route.path;
|
||||||
|
if (path === "/moderation/technical-review") {
|
||||||
|
return formatMessage(messages.technicalReviewTitle);
|
||||||
|
} else if (path.startsWith("/moderation/reports/")) {
|
||||||
|
return formatMessage(messages.reportsTitle);
|
||||||
|
} else {
|
||||||
|
return formatMessage(messages.projectsTitle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(value: string) {
|
||||||
|
navigateToPage(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigateToPage(selectedOption: string) {
|
||||||
|
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
|
||||||
|
router.push("/moderation/technical-review");
|
||||||
|
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
|
||||||
|
router.push("/moderation/reports");
|
||||||
|
} else {
|
||||||
|
router.push("/moderation");
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,42 +1,339 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col gap-3">
|
||||||
<section class="universal-card">
|
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||||
<h2>Statistics</h2>
|
<div class="iconified-input flex-1 lg:max-w-md">
|
||||||
<div class="grid-display">
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||||
<div class="grid-display__item">
|
<input
|
||||||
<div class="label">Projects</div>
|
v-model="query"
|
||||||
<div class="value">
|
class="h-[40px]"
|
||||||
{{ formatNumber(stats.projects, false) }}
|
autocomplete="off"
|
||||||
</div>
|
spellcheck="false"
|
||||||
</div>
|
type="text"
|
||||||
<div class="grid-display__item">
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||||
<div class="label">Versions</div>
|
@input="goToPage(1)"
|
||||||
<div class="value">
|
/>
|
||||||
{{ formatNumber(stats.versions, false) }}
|
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||||
</div>
|
<XIcon />
|
||||||
</div>
|
</Button>
|
||||||
<div class="grid-display__item">
|
|
||||||
<div class="label">Files</div>
|
|
||||||
<div class="value">
|
|
||||||
{{ formatNumber(stats.files, false) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid-display__item">
|
|
||||||
<div class="label">Authors</div>
|
|
||||||
<div class="value">
|
|
||||||
{{ formatNumber(stats.authors, false) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
<ConfettiExplosion v-if="visible" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentFilterType"
|
||||||
|
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||||
|
:name="formatMessage(messages.filterBy)"
|
||||||
|
:options="filterTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<FilterIcon class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentSortType"
|
||||||
|
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||||
|
:name="formatMessage(messages.sortBy)"
|
||||||
|
:options="sortTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||||
|
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonStyled color="orange" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="moderateAllInFilter()"
|
||||||
|
>
|
||||||
|
<ScaleIcon class="size-4 flex-shrink-0" />
|
||||||
|
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
|
||||||
|
<span class="sm:hidden">Moderate</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
<ConfettiExplosion v-if="visible" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||||
|
<ModerationQueueCard
|
||||||
|
v-for="item in paginatedProjects"
|
||||||
|
v-else
|
||||||
|
:key="item.project.id"
|
||||||
|
:queue-entry="item"
|
||||||
|
:owner="item.owner"
|
||||||
|
:org="item.org"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { formatNumber } from "@modrinth/utils";
|
import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
XIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SortAscIcon,
|
||||||
|
SortDescIcon,
|
||||||
|
FilterIcon,
|
||||||
|
ScaleIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import ConfettiExplosion from "vue-confetti-explosion";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import ModerationQueueCard from "~/components/ui/moderation/ModerationQueueCard.vue";
|
||||||
|
import { useModerationStore } from "~/store/moderation.ts";
|
||||||
|
import { enrichProjectBatch, type ModerationProject } from "~/helpers/moderation.ts";
|
||||||
|
|
||||||
useHead({
|
const { formatMessage } = useVIntl();
|
||||||
title: "Staff overview - Modrinth",
|
const moderationStore = useModerationStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
history.state.confetti = false;
|
||||||
|
visible.value = true;
|
||||||
|
await nextTick();
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = false;
|
||||||
|
}, 5000);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
searchPlaceholder: {
|
||||||
|
id: "moderation.search.placeholder",
|
||||||
|
defaultMessage: "Search...",
|
||||||
|
},
|
||||||
|
filterBy: {
|
||||||
|
id: "moderation.filter.by",
|
||||||
|
defaultMessage: "Filter by",
|
||||||
|
},
|
||||||
|
sortBy: {
|
||||||
|
id: "moderation.sort.by",
|
||||||
|
defaultMessage: "Sort by",
|
||||||
|
},
|
||||||
|
moderate: {
|
||||||
|
id: "moderation.moderate",
|
||||||
|
defaultMessage: "Moderate",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
|
const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
let currentOffset = 0;
|
||||||
|
const PROJECT_ENDPOINT_COUNT = 350;
|
||||||
|
const allProjects: ModerationProject[] = [];
|
||||||
|
|
||||||
|
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const projects = (await useBaseFetch(
|
||||||
|
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||||
|
{ internal: true },
|
||||||
|
)) as any[];
|
||||||
|
|
||||||
|
if (projects.length === 0) break;
|
||||||
|
|
||||||
|
const enrichmentPromise = enrichProjectBatch(projects);
|
||||||
|
enrichmentPromises.push(enrichmentPromise);
|
||||||
|
|
||||||
|
currentOffset += projects.length;
|
||||||
|
|
||||||
|
if (enrichmentPromises.length >= 3) {
|
||||||
|
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
|
||||||
|
allProjects.push(...completed.flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingBatches = await Promise.all(enrichmentPromises);
|
||||||
|
allProjects.push(...remainingBatches.flat());
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allProjects;
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = ref(route.query.q?.toString() || "");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
(newQuery) => {
|
||||||
|
const currentQuery = { ...route.query };
|
||||||
|
if (newQuery) {
|
||||||
|
currentQuery.q = newQuery;
|
||||||
|
} else {
|
||||||
|
delete currentQuery.q;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: currentQuery,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(newQueryParam) => {
|
||||||
|
const newValue = newQueryParam?.toString() || "";
|
||||||
|
if (query.value !== newValue) {
|
||||||
|
query.value = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFilterType = useLocalStorage("moderation-current-filter-type", () => "All projects");
|
||||||
|
const filterTypes: readonly string[] = readonly([
|
||||||
|
"All projects",
|
||||||
|
"Modpacks",
|
||||||
|
"Mods",
|
||||||
|
"Resource Packs",
|
||||||
|
"Data Packs",
|
||||||
|
"Plugins",
|
||||||
|
"Shaders",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentSortType = useLocalStorage("moderation-current-sort-type", () => "Oldest");
|
||||||
|
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
|
||||||
|
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const itemsPerPage = 15;
|
||||||
|
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
if (!allProjects.value || allProjects.value.length === 0) return null;
|
||||||
|
return new Fuse(allProjects.value, {
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
name: "project.title",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.slug",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.description",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.project_type",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
"owner.user.username",
|
||||||
|
"org.name",
|
||||||
|
"org.slug",
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResults = computed(() => {
|
||||||
|
if (!query.value || !fuse.value) return null;
|
||||||
|
return fuse.value.search(query.value).map((result) => result.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseFiltered = computed(() => {
|
||||||
|
if (!allProjects.value) return [];
|
||||||
|
return query.value && searchResults.value ? searchResults.value : [...allProjects.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeFiltered = computed(() => {
|
||||||
|
if (currentFilterType.value === "All projects") return baseFiltered.value;
|
||||||
|
|
||||||
|
const filterMap: Record<string, string> = {
|
||||||
|
Modpacks: "modpack",
|
||||||
|
Mods: "mod",
|
||||||
|
"Resource Packs": "resourcepack",
|
||||||
|
"Data Packs": "datapack",
|
||||||
|
Plugins: "plugin",
|
||||||
|
Shaders: "shader",
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectType = filterMap[currentFilterType.value];
|
||||||
|
if (!projectType) return baseFiltered.value;
|
||||||
|
|
||||||
|
return baseFiltered.value.filter((queueItem) =>
|
||||||
|
queueItem.project.project_types.includes(projectType),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredProjects = computed(() => {
|
||||||
|
const filtered = [...typeFiltered.value];
|
||||||
|
|
||||||
|
if (currentSortType.value === "Oldest") {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
|
||||||
|
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
|
||||||
|
return dateA - dateB;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
|
||||||
|
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedProjects = computed(() => {
|
||||||
|
if (!filteredProjects.value) return [];
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage;
|
||||||
|
const end = start + itemsPerPage;
|
||||||
|
return filteredProjects.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
currentPage.value = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderateAllInFilter() {
|
||||||
|
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
|
||||||
|
navigateTo({
|
||||||
|
name: "type-id",
|
||||||
|
params: {
|
||||||
|
type: "project",
|
||||||
|
id: moderationStore.getCurrentProjectId(),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showChecklist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ReportView
|
|
||||||
:auth="auth"
|
|
||||||
:report-id="route.params.id"
|
|
||||||
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import ReportView from "~/components/ui/report/ReportView.vue";
|
|
||||||
|
|
||||||
const auth = await useAuth();
|
|
||||||
const route = useNativeRoute();
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: `Report ${route.params.id} - Modrinth`,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<section class="universal-card">
|
|
||||||
<h2>Reports</h2>
|
|
||||||
<ReportsList :auth="auth" moderation />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import ReportsList from "~/components/ui/report/ReportsList.vue";
|
|
||||||
|
|
||||||
const auth = await useAuth();
|
|
||||||
useHead({
|
|
||||||
title: "Reports - Modrinth",
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
28
apps/frontend/src/pages/moderation/reports/[id].vue
Normal file
28
apps/frontend/src/pages/moderation/reports/[id].vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Report } from "@modrinth/utils";
|
||||||
|
import { enrichReportBatch } from "~/helpers/moderation.ts";
|
||||||
|
import ModerationReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
|
||||||
|
|
||||||
|
const { params } = useRoute();
|
||||||
|
const reportId = params.id as string;
|
||||||
|
|
||||||
|
const { data: report } = await useAsyncData(`moderation-report-${reportId}`, async () => {
|
||||||
|
try {
|
||||||
|
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report;
|
||||||
|
const enrichedReport = (await enrichReportBatch([report]))[0];
|
||||||
|
return enrichedReport;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching report:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "Report not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<ModerationReportCard v-if="report" :report="report" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
290
apps/frontend/src/pages/moderation/reports/index.vue
Normal file
290
apps/frontend/src/pages/moderation/reports/index.vue
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||||
|
<div class="iconified-input flex-1 lg:max-w-md">
|
||||||
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
class="h-[40px]"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||||
|
@input="goToPage(1)"
|
||||||
|
/>
|
||||||
|
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentFilterType"
|
||||||
|
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||||
|
:name="formatMessage(messages.filterBy)"
|
||||||
|
:options="filterTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<FilterIcon class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentSortType"
|
||||||
|
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||||
|
:name="formatMessage(messages.sortBy)"
|
||||||
|
:options="sortTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||||
|
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||||
|
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
|
||||||
|
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
|
||||||
|
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import type { Report } from "@modrinth/utils";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import type { ExtendedReport } from "@modrinth/moderation";
|
||||||
|
import ReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
|
||||||
|
import { enrichReportBatch } from "~/helpers/moderation.ts";
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
searchPlaceholder: {
|
||||||
|
id: "moderation.search.placeholder",
|
||||||
|
defaultMessage: "Search...",
|
||||||
|
},
|
||||||
|
filterBy: {
|
||||||
|
id: "moderation.filter.by",
|
||||||
|
defaultMessage: "Filter by",
|
||||||
|
},
|
||||||
|
sortBy: {
|
||||||
|
id: "moderation.sort.by",
|
||||||
|
defaultMessage: "Sort by",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allReports } = await useLazyAsyncData("new-moderation-reports", async () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
let currentOffset = 0;
|
||||||
|
const REPORT_ENDPOINT_COUNT = 350;
|
||||||
|
const allReports: ExtendedReport[] = [];
|
||||||
|
|
||||||
|
const enrichmentPromises: Promise<ExtendedReport[]>[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const reports = (await useBaseFetch(
|
||||||
|
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||||
|
{ apiVersion: 3 },
|
||||||
|
)) as Report[];
|
||||||
|
|
||||||
|
if (reports.length === 0) break;
|
||||||
|
|
||||||
|
const enrichmentPromise = enrichReportBatch(reports);
|
||||||
|
enrichmentPromises.push(enrichmentPromise);
|
||||||
|
|
||||||
|
currentOffset += reports.length;
|
||||||
|
|
||||||
|
if (enrichmentPromises.length >= 3) {
|
||||||
|
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
|
||||||
|
allReports.push(...completed.flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reports.length < REPORT_ENDPOINT_COUNT) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingBatches = await Promise.all(enrichmentPromises);
|
||||||
|
allReports.push(...remainingBatches.flat());
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allReports;
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = ref(route.query.q?.toString() || "");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
(newQuery) => {
|
||||||
|
const currentQuery = { ...route.query };
|
||||||
|
if (newQuery) {
|
||||||
|
currentQuery.q = newQuery;
|
||||||
|
} else {
|
||||||
|
delete currentQuery.q;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: currentQuery,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(newQueryParam) => {
|
||||||
|
const newValue = newQueryParam?.toString() || "";
|
||||||
|
if (query.value !== newValue) {
|
||||||
|
query.value = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFilterType = useLocalStorage("moderation-reports-filter-type", () => "All");
|
||||||
|
const filterTypes: readonly string[] = readonly(["All", "Unread", "Read"]);
|
||||||
|
|
||||||
|
const currentSortType = useLocalStorage("moderation-reports-sort-type", () => "Oldest");
|
||||||
|
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
|
||||||
|
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const itemsPerPage = 15;
|
||||||
|
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
if (!allReports.value || allReports.value.length === 0) return null;
|
||||||
|
return new Fuse(allReports.value, {
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "body",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "report_type",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "item_id",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reporter_user.username",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
"project.name",
|
||||||
|
"project.slug",
|
||||||
|
"user.username",
|
||||||
|
"version.name",
|
||||||
|
"target.name",
|
||||||
|
"target.slug",
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberRoleMap = computed(() => {
|
||||||
|
if (!allReports.value?.length) return new Map();
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
for (const report of allReports.value) {
|
||||||
|
if (report.thread?.members?.length) {
|
||||||
|
const roleMap = new Map();
|
||||||
|
for (const member of report.thread.members) {
|
||||||
|
roleMap.set(member.id, member.role);
|
||||||
|
}
|
||||||
|
map.set(report.id, roleMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResults = computed(() => {
|
||||||
|
if (!query.value || !fuse.value) return null;
|
||||||
|
return fuse.value.search(query.value).map((result) => result.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseFiltered = computed(() => {
|
||||||
|
if (!allReports.value) return [];
|
||||||
|
return query.value && searchResults.value ? searchResults.value : [...allReports.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeFiltered = computed(() => {
|
||||||
|
if (currentFilterType.value === "All") return baseFiltered.value;
|
||||||
|
|
||||||
|
return baseFiltered.value.filter((report) => {
|
||||||
|
const messages = report.thread?.messages || [];
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return currentFilterType.value === "Unread";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
if (!lastMessage.author_id) return false;
|
||||||
|
|
||||||
|
const roleMap = memberRoleMap.value.get(report.id);
|
||||||
|
if (!roleMap) return false;
|
||||||
|
|
||||||
|
const authorRole = roleMap.get(lastMessage.author_id);
|
||||||
|
const isModeratorMessage = authorRole === "moderator" || authorRole === "admin";
|
||||||
|
|
||||||
|
return currentFilterType.value === "Read" ? isModeratorMessage : !isModeratorMessage;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredReports = computed(() => {
|
||||||
|
const filtered = [...typeFiltered.value];
|
||||||
|
|
||||||
|
if (currentSortType.value === "Oldest") {
|
||||||
|
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
||||||
|
} else {
|
||||||
|
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedReports = computed(() => {
|
||||||
|
if (!filteredReports.value) return [];
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage;
|
||||||
|
const end = start + itemsPerPage;
|
||||||
|
return filteredReports.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
currentPage.value = page;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,304 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="universal-card">
|
|
||||||
<h2>Review projects</h2>
|
|
||||||
<div class="input-group">
|
|
||||||
<Chips
|
|
||||||
v-model="projectType"
|
|
||||||
:items="projectTypes"
|
|
||||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
|
|
||||||
/>
|
|
||||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
|
||||||
<SortDescIcon />
|
|
||||||
Sorting by oldest
|
|
||||||
</button>
|
|
||||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
|
||||||
<SortAscIcon />
|
|
||||||
Sorting by newest
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-highlight"
|
|
||||||
:disabled="projectsFiltered.length === 0"
|
|
||||||
@click="goToProjects()"
|
|
||||||
>
|
|
||||||
<ScaleIcon />
|
|
||||||
Start moderating
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="projectType !== 'all'" class="project-count">
|
|
||||||
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
|
|
||||||
projects in the queue.
|
|
||||||
</p>
|
|
||||||
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
|
|
||||||
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
|
|
||||||
<IssuesIcon />
|
|
||||||
{{ projectsOver24Hours.length }} {{ projectTypePlural }}
|
|
||||||
have been in the queue for over 24 hours.
|
|
||||||
</p>
|
|
||||||
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
|
|
||||||
<IssuesIcon />
|
|
||||||
{{ projectsOver48Hours.length }} {{ projectTypePlural }}
|
|
||||||
have been in the queue for over 48 hours.
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-for="project in projectsFiltered.sort((a, b) => {
|
|
||||||
if (oldestFirst) {
|
|
||||||
return b.age - a.age;
|
|
||||||
} else {
|
|
||||||
return a.age - b.age;
|
|
||||||
}
|
|
||||||
})"
|
|
||||||
:key="`project-${project.id}`"
|
|
||||||
class="universal-card recessed project"
|
|
||||||
>
|
|
||||||
<div class="project-title">
|
|
||||||
<div class="mobile-row">
|
|
||||||
<nuxt-link :to="`/project/${project.id}`" class="iconified-stacked-link">
|
|
||||||
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
|
|
||||||
<span class="stacked">
|
|
||||||
<span class="title">{{ project.name }}</span>
|
|
||||||
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
|
|
||||||
</span>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<div class="mobile-row">
|
|
||||||
by
|
|
||||||
<nuxt-link
|
|
||||||
v-if="project.owner"
|
|
||||||
:to="`/user/${project.owner.user.id}`"
|
|
||||||
class="iconified-link"
|
|
||||||
>
|
|
||||||
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
|
|
||||||
<span>{{ project.owner.user.username }}</span>
|
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link
|
|
||||||
v-else-if="project.org"
|
|
||||||
:to="`/organization/${project.org.id}`"
|
|
||||||
class="iconified-link"
|
|
||||||
>
|
|
||||||
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
|
|
||||||
<span>{{ project.org.name }}</span>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<div class="mobile-row">
|
|
||||||
is requesting to be
|
|
||||||
<ProjectStatusBadge
|
|
||||||
:status="project.requested_status ? project.requested_status : 'approved'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
|
|
||||||
<EyeIcon />
|
|
||||||
View project
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
|
|
||||||
<IssuesIcon v-if="project.age_warning" />
|
|
||||||
Submitted
|
|
||||||
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
|
|
||||||
formatRelativeTime(project.queued)
|
|
||||||
}}</span>
|
|
||||||
</span>
|
|
||||||
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui";
|
|
||||||
import {
|
|
||||||
UnknownIcon,
|
|
||||||
EyeIcon,
|
|
||||||
SortAscIcon,
|
|
||||||
SortDescIcon,
|
|
||||||
IssuesIcon,
|
|
||||||
ScaleIcon,
|
|
||||||
} from "@modrinth/assets";
|
|
||||||
import { formatProjectType } from "@modrinth/utils";
|
|
||||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: "Review projects - Modrinth",
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = useNuxtApp();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const now = app.$dayjs();
|
|
||||||
const TIME_24H = 86400000;
|
|
||||||
const TIME_48H = TIME_24H * 2;
|
|
||||||
|
|
||||||
const formatRelativeTime = useRelativeTime();
|
|
||||||
|
|
||||||
const { data: projects } = await useAsyncData("moderation/projects?count=1000", () =>
|
|
||||||
useBaseFetch("moderation/projects?count=1000", { internal: true }),
|
|
||||||
);
|
|
||||||
const members = ref([]);
|
|
||||||
const projectType = ref("all");
|
|
||||||
const oldestFirst = ref(true);
|
|
||||||
|
|
||||||
const projectsFiltered = computed(() =>
|
|
||||||
projects.value.filter(
|
|
||||||
(x) =>
|
|
||||||
projectType.value === "all" ||
|
|
||||||
app.$getProjectTypeForUrl(x.project_types[0], x.loaders) === projectType.value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectsOver24Hours = computed(() =>
|
|
||||||
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H),
|
|
||||||
);
|
|
||||||
const projectsOver48Hours = computed(() =>
|
|
||||||
projectsFiltered.value.filter((project) => project.age >= TIME_48H),
|
|
||||||
);
|
|
||||||
const projectTypePlural = computed(() =>
|
|
||||||
projectType.value === "all"
|
|
||||||
? "projects"
|
|
||||||
: (formatProjectType(projectType.value) + "s").toLowerCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectTypes = computed(() => {
|
|
||||||
const set = new Set();
|
|
||||||
set.add("all");
|
|
||||||
|
|
||||||
if (projects.value) {
|
|
||||||
for (const project of projects.value) {
|
|
||||||
set.add(project.inferred_project_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...set];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (projects.value) {
|
|
||||||
const teamIds = projects.value.map((x) => x.team_id);
|
|
||||||
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
|
|
||||||
|
|
||||||
const [{ data: teams }, { data: orgs }] = await Promise.all([
|
|
||||||
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
|
|
||||||
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
|
|
||||||
),
|
|
||||||
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
|
|
||||||
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
|
||||||
apiVersion: 3,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (teams.value) {
|
|
||||||
members.value = teams.value;
|
|
||||||
|
|
||||||
projects.value = projects.value.map((project) => {
|
|
||||||
project.owner = members.value
|
|
||||||
? members.value.flat().find((x) => x.team_id === project.team_id && x.role === "Owner")
|
|
||||||
: null;
|
|
||||||
project.org = orgs.value ? orgs.value.find((x) => x.id === project.organization) : null;
|
|
||||||
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE;
|
|
||||||
project.age_warning = "";
|
|
||||||
if (project.age > TIME_24H * 2) {
|
|
||||||
project.age_warning = "danger";
|
|
||||||
} else if (project.age > TIME_24H) {
|
|
||||||
project.age_warning = "warning";
|
|
||||||
}
|
|
||||||
project.inferred_project_type = app.$getProjectTypeForUrl(
|
|
||||||
project.project_types[0],
|
|
||||||
project.loaders,
|
|
||||||
);
|
|
||||||
return project;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToProjects() {
|
|
||||||
const project = projectsFiltered.value[0];
|
|
||||||
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
|
|
||||||
|
|
||||||
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
|
|
||||||
|
|
||||||
await router.push({
|
|
||||||
name: "type-id",
|
|
||||||
params: {
|
|
||||||
type: project.project_types[0],
|
|
||||||
id: project.slug ? project.slug : project.id,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
showChecklist: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.project {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-card-sm);
|
|
||||||
@media screen and (min-width: 650px) {
|
|
||||||
display: grid;
|
|
||||||
grid-template: "title action" "date action";
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.submitter-info {
|
|
||||||
margin: 0;
|
|
||||||
grid-area: date;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
color: var(--color-orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger {
|
|
||||||
color: var(--color-red);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-count {
|
|
||||||
margin-block: var(--spacing-card-md);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group {
|
|
||||||
grid-area: action;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-title {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-card-xs);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.mobile-row {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 800px) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
.mobile-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--spacing-card-xs);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.avatar) {
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.size-xs {
|
|
||||||
margin-right: var(--spacing-card-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
386
apps/frontend/src/pages/moderation/technical-review-mockup.vue
Normal file
386
apps/frontend/src/pages/moderation/technical-review-mockup.vue
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||||
|
<div class="iconified-input flex-1 lg:max-w-md">
|
||||||
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
class="h-[40px]"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||||
|
@input="updateSearchResults()"
|
||||||
|
/>
|
||||||
|
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentFilterType"
|
||||||
|
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||||
|
:name="formatMessage(messages.filterBy)"
|
||||||
|
:options="filterTypes as unknown[]"
|
||||||
|
@change="updateSearchResults()"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<FilterIcon class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentSortType"
|
||||||
|
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||||
|
:name="formatMessage(messages.sortBy)"
|
||||||
|
:options="sortTypes as unknown[]"
|
||||||
|
@change="updateSearchResults()"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||||
|
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<DelphiReportCard
|
||||||
|
v-for="report in paginatedReports"
|
||||||
|
:key="report.version.id"
|
||||||
|
:report="report"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="!paginatedReports || paginatedReports.length === 0"
|
||||||
|
class="universal-card h-24 animate-pulse"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
|
||||||
|
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
|
||||||
|
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import type { TeamMember, Organization, DelphiReport, Project, Version } from "@modrinth/utils";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import type { OwnershipTarget, ExtendedDelphiReport } from "@modrinth/moderation";
|
||||||
|
import DelphiReportCard from "~/components/ui/moderation/ModerationDelphiReportCard.vue";
|
||||||
|
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
searchPlaceholder: {
|
||||||
|
id: "moderation.technical.search.placeholder",
|
||||||
|
defaultMessage: "Search tech reviews...",
|
||||||
|
},
|
||||||
|
filterBy: {
|
||||||
|
id: "moderation.filter.by",
|
||||||
|
defaultMessage: "Filter by",
|
||||||
|
},
|
||||||
|
sortBy: {
|
||||||
|
id: "moderation.sort.by",
|
||||||
|
defaultMessage: "Sort by",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getProjectQuicklyForMock(projectId: string): Promise<Project> {
|
||||||
|
return (await useBaseFetch(`project/${projectId}`)) as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVersionQuicklyForMock(versionId: string): Promise<Version> {
|
||||||
|
return (await useBaseFetch(`version/${versionId}`)) as Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDelphiReports: DelphiReport[] = [
|
||||||
|
{
|
||||||
|
project: await getProjectQuicklyForMock("7MoE34WK"),
|
||||||
|
version: await getVersionQuicklyForMock("cTkKLWgA"),
|
||||||
|
trace_type: "url_usage",
|
||||||
|
file_path: "me/decce/gnetum/ASMEventHandlerHelper.java",
|
||||||
|
priority_score: 29,
|
||||||
|
status: "pending",
|
||||||
|
detected_at: "2025-04-01T12:00:00Z",
|
||||||
|
} as DelphiReport,
|
||||||
|
{
|
||||||
|
project: await getProjectQuicklyForMock("7MoE34WK"),
|
||||||
|
version: await getVersionQuicklyForMock("cTkKLWgA"),
|
||||||
|
trace_type: "url_usage",
|
||||||
|
file_path: "me/decce/gnetum/SomeOtherFile.java",
|
||||||
|
priority_score: 48,
|
||||||
|
status: "rejected",
|
||||||
|
detected_at: "2025-03-02T12:00:00Z",
|
||||||
|
} as DelphiReport,
|
||||||
|
{
|
||||||
|
project: await getProjectQuicklyForMock("7MoE34WK"),
|
||||||
|
version: await getVersionQuicklyForMock("cTkKLWgA"),
|
||||||
|
trace_type: "url_usage",
|
||||||
|
file_path: "me/decce/gnetum/YetAnotherFile.java",
|
||||||
|
priority_score: 15,
|
||||||
|
status: "approved",
|
||||||
|
detected_at: "2025-02-03T12:00:00Z",
|
||||||
|
} as DelphiReport,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { data: allReports } = await useAsyncData("moderation-tech-reviews", async () => {
|
||||||
|
// TODO: replace with actual API call
|
||||||
|
const delphiReports = mockDelphiReports;
|
||||||
|
|
||||||
|
if (delphiReports.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))];
|
||||||
|
const orgIds = [
|
||||||
|
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
|
||||||
|
teamIds.length > 0
|
||||||
|
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
orgIds.length > 0
|
||||||
|
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||||
|
apiVersion: 3,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean);
|
||||||
|
const orgTeamsData: TeamMember[][] =
|
||||||
|
orgTeamIds.length > 0
|
||||||
|
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const teamMap = new Map<string, TeamMember[]>();
|
||||||
|
const orgMap = new Map<string, Organization>();
|
||||||
|
|
||||||
|
teamsData.forEach((team) => {
|
||||||
|
let teamId = null;
|
||||||
|
for (const member of team) {
|
||||||
|
teamId = member.team_id;
|
||||||
|
if (!teamMap.has(teamId)) {
|
||||||
|
teamMap.set(teamId, team);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
orgTeamsData.forEach((team) => {
|
||||||
|
let teamId = null;
|
||||||
|
for (const member of team) {
|
||||||
|
teamId = member.team_id;
|
||||||
|
if (!teamMap.has(teamId)) {
|
||||||
|
teamMap.set(teamId, team);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
orgsData.forEach((org: Organization) => {
|
||||||
|
orgMap.set(org.id, org);
|
||||||
|
});
|
||||||
|
|
||||||
|
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
|
||||||
|
let target: OwnershipTarget | undefined;
|
||||||
|
const project = report.project;
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
let owner: TeamMember | null = null;
|
||||||
|
let org: Organization | null = null;
|
||||||
|
|
||||||
|
if (project.team) {
|
||||||
|
const teamMembers = teamMap.get(project.team);
|
||||||
|
if (teamMembers) {
|
||||||
|
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.organization) {
|
||||||
|
org = orgMap.get(project.organization) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
target = {
|
||||||
|
name: org.name,
|
||||||
|
avatar_url: org.icon_url,
|
||||||
|
type: "organization",
|
||||||
|
slug: org.slug,
|
||||||
|
};
|
||||||
|
} else if (owner) {
|
||||||
|
target = {
|
||||||
|
name: owner.user.username,
|
||||||
|
avatar_url: owner.user.avatar_url,
|
||||||
|
type: "user",
|
||||||
|
slug: owner.user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...report,
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
extendedReports.sort((a, b) => b.priority_score - a.priority_score);
|
||||||
|
|
||||||
|
return extendedReports;
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = ref(route.query.q?.toString() || "");
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
(newQuery) => {
|
||||||
|
const currentQuery = { ...route.query };
|
||||||
|
if (newQuery) {
|
||||||
|
currentQuery.q = newQuery;
|
||||||
|
} else {
|
||||||
|
delete currentQuery.q;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: currentQuery,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(newQueryParam) => {
|
||||||
|
const newValue = newQueryParam?.toString() || "";
|
||||||
|
if (query.value !== newValue) {
|
||||||
|
query.value = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFilterType = useLocalStorage("moderation-tech-reviews-filter-type", () => "Pending");
|
||||||
|
const filterTypes: readonly string[] = readonly(["All", "Pending", "Approved", "Rejected"]);
|
||||||
|
|
||||||
|
const currentSortType = useLocalStorage("moderation-tech-reviews-sort-type", () => "Priority");
|
||||||
|
const sortTypes: readonly string[] = readonly(["Priority", "Oldest", "Newest"]);
|
||||||
|
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const itemsPerPage = 15;
|
||||||
|
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
if (!allReports.value || allReports.value.length === 0) return null;
|
||||||
|
return new Fuse(allReports.value, {
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
name: "version.id",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version.version_number",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.title",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.slug",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version.files.filename",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trace_type",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
weight: 0.5,
|
||||||
|
},
|
||||||
|
"file_path",
|
||||||
|
"project.id",
|
||||||
|
"target.name",
|
||||||
|
"target.slug",
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredReports = computed(() => {
|
||||||
|
if (!allReports.value) return [];
|
||||||
|
|
||||||
|
let filtered;
|
||||||
|
|
||||||
|
if (query.value && fuse.value) {
|
||||||
|
const results = fuse.value.search(query.value);
|
||||||
|
filtered = results.map((result) => result.item);
|
||||||
|
} else {
|
||||||
|
filtered = [...allReports.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFilterType.value === "Pending") {
|
||||||
|
filtered = filtered.filter((report) => report.status === "pending");
|
||||||
|
} else if (currentFilterType.value === "Approved") {
|
||||||
|
filtered = filtered.filter((report) => report.status === "approved");
|
||||||
|
} else if (currentFilterType.value === "Rejected") {
|
||||||
|
filtered = filtered.filter((report) => report.status === "rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSortType.value === "Priority") {
|
||||||
|
filtered.sort((a, b) => b.priority_score - a.priority_score);
|
||||||
|
} else if (currentSortType.value === "Oldest") {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.detected_at).getTime();
|
||||||
|
const dateB = new Date(b.detected_at).getTime();
|
||||||
|
return dateA - dateB;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.detected_at).getTime();
|
||||||
|
const dateB = new Date(b.detected_at).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedReports = computed(() => {
|
||||||
|
if (!filteredReports.value) return [];
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage;
|
||||||
|
const end = start + itemsPerPage;
|
||||||
|
return filteredReports.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSearchResults() {
|
||||||
|
currentPage.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
currentPage.value = page;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
3
apps/frontend/src/pages/moderation/technical-review.vue
Normal file
3
apps/frontend/src/pages/moderation/technical-review.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<p>Not yet implemented.</p>
|
||||||
|
</template>
|
||||||
98
apps/frontend/src/store/moderation.ts
Normal file
98
apps/frontend/src/store/moderation.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { defineStore, createPinia } from "pinia";
|
||||||
|
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
|
||||||
|
|
||||||
|
export interface ModerationQueue {
|
||||||
|
items: string[];
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
skipped: number;
|
||||||
|
lastUpdated: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_QUEUE: Partial<ModerationQueue> = {
|
||||||
|
items: [],
|
||||||
|
|
||||||
|
// TODO: Consider some form of displaying this in the checklist, maybe at the end
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createEmptyQueue(): ModerationQueue {
|
||||||
|
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinia = createPinia();
|
||||||
|
pinia.use(piniaPluginPersistedstate);
|
||||||
|
|
||||||
|
export const useModerationStore = defineStore("moderation", {
|
||||||
|
state: () => ({
|
||||||
|
currentQueue: createEmptyQueue(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
queueLength: (state) => state.currentQueue.items.length,
|
||||||
|
hasItems: (state) => state.currentQueue.items.length > 0,
|
||||||
|
progress: (state) => {
|
||||||
|
if (state.currentQueue.total === 0) return 0;
|
||||||
|
return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
setQueue(projectIDs: string[]) {
|
||||||
|
this.currentQueue = {
|
||||||
|
items: [...projectIDs],
|
||||||
|
total: projectIDs.length,
|
||||||
|
completed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setSingleProject(projectId: string) {
|
||||||
|
this.currentQueue = {
|
||||||
|
items: [projectId],
|
||||||
|
total: 1,
|
||||||
|
completed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
completeCurrentProject(projectId: string, status: "completed" | "skipped" = "completed") {
|
||||||
|
if (status === "completed") {
|
||||||
|
this.currentQueue.completed++;
|
||||||
|
} else {
|
||||||
|
this.currentQueue.skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId);
|
||||||
|
this.currentQueue.lastUpdated = new Date();
|
||||||
|
|
||||||
|
return this.currentQueue.items.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentProjectId(): string | null {
|
||||||
|
return this.currentQueue.items[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetQueue() {
|
||||||
|
this.currentQueue = createEmptyQueue();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
persist: {
|
||||||
|
key: "moderation-store",
|
||||||
|
serializer: {
|
||||||
|
serialize: JSON.stringify,
|
||||||
|
deserialize: (value: string) => {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed.currentQueue?.lastUpdated) {
|
||||||
|
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ",
|
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@ -25,10 +25,11 @@
|
|||||||
"Text",
|
"Text",
|
||||||
"Text",
|
"Text",
|
||||||
"Text",
|
"Text",
|
||||||
|
"Bool",
|
||||||
"Bool"
|
"Bool"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55"
|
"hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -122,6 +122,11 @@
|
|||||||
"ordinal": 23,
|
"ordinal": 23,
|
||||||
"name": "allow_friend_requests",
|
"name": "allow_friend_requests",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 24,
|
||||||
|
"name": "is_subscribed_to_newsletter",
|
||||||
|
"type_info": "Bool"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@ -154,8 +159,9 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0"
|
"hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
|
||||||
}
|
}
|
||||||
14
apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json
generated
Normal file
14
apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json
generated
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users\n SET is_subscribed_to_newsletter = TRUE\n WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623"
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
use super::AuthProvider;
|
use super::AuthProvider;
|
||||||
use crate::auth::AuthenticationError;
|
use crate::auth::AuthenticationError;
|
||||||
use crate::database::models::user_item;
|
use crate::database::models::{DBUser, user_item};
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
use crate::models::users::User;
|
use crate::models::users::User;
|
||||||
@ -44,17 +44,16 @@ where
|
|||||||
Ok(Some((scopes, User::from_full(db_user))))
|
Ok(Some((scopes, User::from_full(db_user))))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_from_headers<'a, E>(
|
pub async fn get_full_user_from_headers<'a, E>(
|
||||||
req: &HttpRequest,
|
req: &HttpRequest,
|
||||||
executor: E,
|
executor: E,
|
||||||
redis: &RedisPool,
|
redis: &RedisPool,
|
||||||
session_queue: &AuthQueue,
|
session_queue: &AuthQueue,
|
||||||
required_scopes: Scopes,
|
required_scopes: Scopes,
|
||||||
) -> Result<(Scopes, User), AuthenticationError>
|
) -> Result<(Scopes, DBUser), AuthenticationError>
|
||||||
where
|
where
|
||||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||||
{
|
{
|
||||||
// Fetch DB user record and minos user from headers
|
|
||||||
let (scopes, db_user) = get_user_record_from_bearer_token(
|
let (scopes, db_user) = get_user_record_from_bearer_token(
|
||||||
req,
|
req,
|
||||||
None,
|
None,
|
||||||
@ -65,13 +64,33 @@ where
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||||
|
|
||||||
let user = User::from_full(db_user);
|
|
||||||
|
|
||||||
if !scopes.contains(required_scopes) {
|
if !scopes.contains(required_scopes) {
|
||||||
return Err(AuthenticationError::InvalidCredentials);
|
return Err(AuthenticationError::InvalidCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((scopes, user))
|
Ok((scopes, db_user))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_from_headers<'a, E>(
|
||||||
|
req: &HttpRequest,
|
||||||
|
executor: E,
|
||||||
|
redis: &RedisPool,
|
||||||
|
session_queue: &AuthQueue,
|
||||||
|
required_scopes: Scopes,
|
||||||
|
) -> Result<(Scopes, User), AuthenticationError>
|
||||||
|
where
|
||||||
|
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||||
|
{
|
||||||
|
let (scopes, db_user) = get_full_user_from_headers(
|
||||||
|
req,
|
||||||
|
executor,
|
||||||
|
redis,
|
||||||
|
session_queue,
|
||||||
|
required_scopes,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((scopes, User::from_full(db_user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
||||||
|
|||||||
@ -49,6 +49,8 @@ pub struct DBUser {
|
|||||||
pub badges: Badges,
|
pub badges: Badges,
|
||||||
|
|
||||||
pub allow_friend_requests: bool,
|
pub allow_friend_requests: bool,
|
||||||
|
|
||||||
|
pub is_subscribed_to_newsletter: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DBUser {
|
impl DBUser {
|
||||||
@ -63,13 +65,13 @@ impl DBUser {
|
|||||||
avatar_url, raw_avatar_url, bio, created,
|
avatar_url, raw_avatar_url, bio, created,
|
||||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||||
email_verified, password, paypal_id, paypal_country, paypal_email,
|
email_verified, password, paypal_id, paypal_country, paypal_email,
|
||||||
venmo_handle, stripe_customer_id, allow_friend_requests
|
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6, $7,
|
$6, $7,
|
||||||
$8, $9, $10, $11, $12, $13,
|
$8, $9, $10, $11, $12, $13,
|
||||||
$14, $15, $16, $17, $18, $19, $20, $21
|
$14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
self.id as DBUserId,
|
self.id as DBUserId,
|
||||||
@ -93,6 +95,7 @@ impl DBUser {
|
|||||||
self.venmo_handle,
|
self.venmo_handle,
|
||||||
self.stripe_customer_id,
|
self.stripe_customer_id,
|
||||||
self.allow_friend_requests,
|
self.allow_friend_requests,
|
||||||
|
self.is_subscribed_to_newsletter,
|
||||||
)
|
)
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@ -178,7 +181,7 @@ impl DBUser {
|
|||||||
created, role, badges,
|
created, role, badges,
|
||||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||||
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
|
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
|
||||||
venmo_handle, stripe_customer_id, allow_friend_requests
|
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
||||||
",
|
",
|
||||||
@ -212,6 +215,7 @@ impl DBUser {
|
|||||||
stripe_customer_id: u.stripe_customer_id,
|
stripe_customer_id: u.stripe_customer_id,
|
||||||
totp_secret: u.totp_secret,
|
totp_secret: u.totp_secret,
|
||||||
allow_friend_requests: u.allow_friend_requests,
|
allow_friend_requests: u.allow_friend_requests,
|
||||||
|
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc.insert(u.id, (Some(u.username), user));
|
acc.insert(u.id, (Some(u.username), user));
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
use crate::auth::email::send_email;
|
use crate::auth::email::send_email;
|
||||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
use crate::auth::validate::{
|
||||||
|
get_full_user_from_headers, get_user_record_from_bearer_token,
|
||||||
|
};
|
||||||
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
|
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
|
||||||
use crate::database::models::DBUser;
|
use crate::database::models::DBUser;
|
||||||
use crate::database::models::flow_item::DBFlow;
|
use crate::database::models::flow_item::DBFlow;
|
||||||
@ -232,6 +234,7 @@ impl TempUser {
|
|||||||
role: Role::Developer.to_string(),
|
role: Role::Developer.to_string(),
|
||||||
badges: Badges::default(),
|
badges: Badges::default(),
|
||||||
allow_friend_requests: true,
|
allow_friend_requests: true,
|
||||||
|
is_subscribed_to_newsletter: false,
|
||||||
}
|
}
|
||||||
.insert(transaction)
|
.insert(transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@ -1291,37 +1294,6 @@ pub async fn delete_auth_provider(
|
|||||||
Ok(HttpResponse::NoContent().finish())
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> {
|
|
||||||
let url = dotenvy::var("SENDY_URL")?;
|
|
||||||
let id = dotenvy::var("SENDY_LIST_ID")?;
|
|
||||||
let api_key = dotenvy::var("SENDY_API_KEY")?;
|
|
||||||
let site_url = dotenvy::var("SITE_URL")?;
|
|
||||||
|
|
||||||
if url.is_empty() || url == "none" {
|
|
||||||
tracing::info!("Sendy URL not set, skipping signup");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut form = HashMap::new();
|
|
||||||
|
|
||||||
form.insert("api_key", &*api_key);
|
|
||||||
form.insert("email", email);
|
|
||||||
form.insert("list", &*id);
|
|
||||||
form.insert("referrer", &*site_url);
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
client
|
|
||||||
.post(format!("{url}/subscribe"))
|
|
||||||
.form(&form)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.text()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn check_sendy_subscription(
|
pub async fn check_sendy_subscription(
|
||||||
email: &str,
|
email: &str,
|
||||||
) -> Result<bool, AuthenticationError> {
|
) -> Result<bool, AuthenticationError> {
|
||||||
@ -1456,6 +1428,9 @@ pub async fn create_account_with_password(
|
|||||||
role: Role::Developer.to_string(),
|
role: Role::Developer.to_string(),
|
||||||
badges: Badges::default(),
|
badges: Badges::default(),
|
||||||
allow_friend_requests: true,
|
allow_friend_requests: true,
|
||||||
|
is_subscribed_to_newsletter: new_account
|
||||||
|
.sign_up_newsletter
|
||||||
|
.unwrap_or(false),
|
||||||
}
|
}
|
||||||
.insert(&mut transaction)
|
.insert(&mut transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@ -1476,10 +1451,6 @@ pub async fn create_account_with_password(
|
|||||||
&format!("Welcome to Modrinth, {}!", new_account.username),
|
&format!("Welcome to Modrinth, {}!", new_account.username),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if new_account.sign_up_newsletter.unwrap_or(false) {
|
|
||||||
sign_up_sendy(&new_account.email).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(res))
|
Ok(HttpResponse::Ok().json(res))
|
||||||
@ -2420,15 +2391,24 @@ pub async fn subscribe_newsletter(
|
|||||||
.await?
|
.await?
|
||||||
.1;
|
.1;
|
||||||
|
|
||||||
if let Some(email) = user.email {
|
sqlx::query!(
|
||||||
sign_up_sendy(&email).await?;
|
"
|
||||||
|
UPDATE users
|
||||||
|
SET is_subscribed_to_newsletter = TRUE
|
||||||
|
WHERE id = $1
|
||||||
|
",
|
||||||
|
user.id.0 as i64,
|
||||||
|
)
|
||||||
|
.execute(&**pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(HttpResponse::NoContent().finish())
|
crate::database::models::DBUser::clear_caches(
|
||||||
} else {
|
&[(user.id.into(), None)],
|
||||||
Err(ApiError::InvalidInput(
|
&redis,
|
||||||
"User does not have an email.".to_string(),
|
)
|
||||||
))
|
.await?;
|
||||||
}
|
|
||||||
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("email/subscribe")]
|
#[get("email/subscribe")]
|
||||||
@ -2438,7 +2418,7 @@ pub async fn get_newsletter_subscription_status(
|
|||||||
redis: Data<RedisPool>,
|
redis: Data<RedisPool>,
|
||||||
session_queue: Data<AuthQueue>,
|
session_queue: Data<AuthQueue>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let user = get_user_from_headers(
|
let user = get_full_user_from_headers(
|
||||||
&req,
|
&req,
|
||||||
&**pool,
|
&**pool,
|
||||||
&redis,
|
&redis,
|
||||||
@ -2448,16 +2428,16 @@ pub async fn get_newsletter_subscription_status(
|
|||||||
.await?
|
.await?
|
||||||
.1;
|
.1;
|
||||||
|
|
||||||
if let Some(email) = user.email {
|
let is_subscribed = user.is_subscribed_to_newsletter
|
||||||
let is_subscribed = check_sendy_subscription(&email).await?;
|
|| if let Some(email) = user.email {
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
check_sendy_subscription(&email).await?
|
||||||
"subscribed": is_subscribed
|
} else {
|
||||||
})))
|
false
|
||||||
} else {
|
};
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
|
||||||
"subscribed": false
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
})))
|
"subscribed": is_subscribed
|
||||||
}
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_email_verify(
|
fn send_email_verify(
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import _CodeIcon from './icons/code.svg?component'
|
|||||||
import _CoffeeIcon from './icons/coffee.svg?component'
|
import _CoffeeIcon from './icons/coffee.svg?component'
|
||||||
import _CogIcon from './icons/cog.svg?component'
|
import _CogIcon from './icons/cog.svg?component'
|
||||||
import _CoinsIcon from './icons/coins.svg?component'
|
import _CoinsIcon from './icons/coins.svg?component'
|
||||||
|
import _CollapseIcon from './icons/collapse.svg?component'
|
||||||
import _CollectionIcon from './icons/collection.svg?component'
|
import _CollectionIcon from './icons/collection.svg?component'
|
||||||
import _CompassIcon from './icons/compass.svg?component'
|
import _CompassIcon from './icons/compass.svg?component'
|
||||||
import _ContractIcon from './icons/contract.svg?component'
|
import _ContractIcon from './icons/contract.svg?component'
|
||||||
@ -52,6 +53,7 @@ import _DatabaseIcon from './icons/database.svg?component'
|
|||||||
import _DownloadIcon from './icons/download.svg?component'
|
import _DownloadIcon from './icons/download.svg?component'
|
||||||
import _DropdownIcon from './icons/dropdown.svg?component'
|
import _DropdownIcon from './icons/dropdown.svg?component'
|
||||||
import _EditIcon from './icons/edit.svg?component'
|
import _EditIcon from './icons/edit.svg?component'
|
||||||
|
import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
|
||||||
import _ExpandIcon from './icons/expand.svg?component'
|
import _ExpandIcon from './icons/expand.svg?component'
|
||||||
import _ExternalIcon from './icons/external.svg?component'
|
import _ExternalIcon from './icons/external.svg?component'
|
||||||
import _EyeOffIcon from './icons/eye-off.svg?component'
|
import _EyeOffIcon from './icons/eye-off.svg?component'
|
||||||
@ -229,6 +231,7 @@ export const CodeIcon = _CodeIcon
|
|||||||
export const CoffeeIcon = _CoffeeIcon
|
export const CoffeeIcon = _CoffeeIcon
|
||||||
export const CogIcon = _CogIcon
|
export const CogIcon = _CogIcon
|
||||||
export const CoinsIcon = _CoinsIcon
|
export const CoinsIcon = _CoinsIcon
|
||||||
|
export const CollapseIcon = _CollapseIcon
|
||||||
export const CollectionIcon = _CollectionIcon
|
export const CollectionIcon = _CollectionIcon
|
||||||
export const CompassIcon = _CompassIcon
|
export const CompassIcon = _CompassIcon
|
||||||
export const ContractIcon = _ContractIcon
|
export const ContractIcon = _ContractIcon
|
||||||
@ -243,6 +246,7 @@ export const DatabaseIcon = _DatabaseIcon
|
|||||||
export const DownloadIcon = _DownloadIcon
|
export const DownloadIcon = _DownloadIcon
|
||||||
export const DropdownIcon = _DropdownIcon
|
export const DropdownIcon = _DropdownIcon
|
||||||
export const EditIcon = _EditIcon
|
export const EditIcon = _EditIcon
|
||||||
|
export const EllipsisVerticalIcon = _EllipsisVerticalIcon
|
||||||
export const ExpandIcon = _ExpandIcon
|
export const ExpandIcon = _ExpandIcon
|
||||||
export const ExternalIcon = _ExternalIcon
|
export const ExternalIcon = _ExternalIcon
|
||||||
export const EyeOffIcon = _EyeOffIcon
|
export const EyeOffIcon = _EyeOffIcon
|
||||||
|
|||||||
8
packages/assets/icons/collapse.svg
Normal file
8
packages/assets/icons/collapse.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
class="lucide lucide-minimize-icon lucide-minimize">
|
||||||
|
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||||
|
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||||
|
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||||
|
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 414 B |
1
packages/assets/icons/ellipsis-vertical.svg
Normal file
1
packages/assets/icons/ellipsis-vertical.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical-icon lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
3
packages/moderation/data/messages/reports/antivirus.md
Normal file
3
packages/moderation/data/messages/reports/antivirus.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Unfortunately, anti-virus software has consistently been found to be an unreliable tool for Minecraft mods.
|
||||||
|
|
||||||
|
If you have evidence of malicious activity concerning a specific mod, or of malicious code decompiled from a mod on Modrinth, please create a new Report and provide the required details, thank you.
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
Thank you for your report.
|
||||||
|
|
||||||
|
This project was confirmed to be malicious after a detailed investigation. Luckily, thanks to your report and quick action from our team, we have reason to believe this did not impact a significant amount of users and we have taken precautions to prevent this malicious code from appearing on Modrinth again.
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
Unfortunately, the Moderation team is unable to assist with your issue.
|
||||||
|
|
||||||
|
The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report.
|
||||||
|
|
||||||
|
If you are having issues with crashes, please check out [our FAQ section](https://support.modrinth.com/aen/articles/8792916) to learn how to diagnose and fix crashes.
|
||||||
|
For other project-specific issues consider asking the project's own community, check for a Discord or Issues link on the project page.
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
Unfortunately, the Moderation team is unable to assist with your issue.
|
||||||
|
|
||||||
|
The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported.
|
||||||
|
|
||||||
|
Please reach out to the [Modrinth Help Center](https://support.modrinth.com/) so we can better assist you and bring up your concerns with our platform tean,
|
||||||
3
packages/moderation/data/messages/reports/spam.md
Normal file
3
packages/moderation/data/messages/reports/spam.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
The reporting system is exclusively for reporting issues to Modrinth staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report.
|
||||||
|
|
||||||
|
Please ensure you are using the Reports system appropriately, repeated misuse may result in account suspension.
|
||||||
3
packages/moderation/data/messages/reports/stale.md
Normal file
3
packages/moderation/data/messages/reports/stale.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
We haven't received a response in some time, so we're closing this report thread.
|
||||||
|
|
||||||
|
If you have additional information to share we ask that you create a new report.
|
||||||
34
packages/moderation/data/report-quick-replies.ts
Normal file
34
packages/moderation/data/report-quick-replies.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { ReportQuickReply } from '../types/reports'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
label: 'Antivirus',
|
||||||
|
message: async () => (await import('./messages/reports/antivirus.md?raw')).default,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Spam',
|
||||||
|
message: async () => (await import('./messages/reports/spam.md?raw')).default,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gameplay Issue',
|
||||||
|
message: async () => (await import('./messages/reports/gameplay-issue.md?raw')).default,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Platform Issue',
|
||||||
|
message: async () => (await import('./messages/reports/platform-issue.md?raw')).default,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stale',
|
||||||
|
message: async () => (await import('./messages/reports/stale.md?raw')).default,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Confirmed Malware',
|
||||||
|
message: async () => (await import('./messages/reports/confirmed-malware.md?raw')).default,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
] as ReadonlyArray<ReportQuickReply>
|
||||||
@ -68,7 +68,7 @@ const versions: Stage = {
|
|||||||
message: async () => '',
|
message: async () => '',
|
||||||
enablesActions: [
|
enablesActions: [
|
||||||
{
|
{
|
||||||
id: 'versions_incorrect_project_type_options',
|
id: 'versions_alternate_versions_options',
|
||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
label: 'How are the alternate versions distributed?',
|
label: 'How are the alternate versions distributed?',
|
||||||
options: [
|
options: [
|
||||||
|
|||||||
@ -3,10 +3,12 @@ export * from './types/messages'
|
|||||||
export * from './types/stage'
|
export * from './types/stage'
|
||||||
export * from './types/keybinds'
|
export * from './types/keybinds'
|
||||||
export * from './types/nags'
|
export * from './types/nags'
|
||||||
|
export * from './types/reports'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
export { finalPermissionMessages } from './data/modpack-permissions-stage'
|
|
||||||
|
|
||||||
export * from './data/nags/index'
|
export * from './data/nags/index'
|
||||||
|
export { default as nags } from './data/nags'
|
||||||
|
export { finalPermissionMessages } from './data/modpack-permissions-stage'
|
||||||
export { default as checklist } from './data/checklist'
|
export { default as checklist } from './data/checklist'
|
||||||
export { default as keybinds } from './data/keybinds'
|
export { default as keybinds } from './data/keybinds'
|
||||||
export { default as nags } from './data/nags'
|
export { default as reportQuickReplies } from './data/report-quick-replies'
|
||||||
|
|||||||
28
packages/moderation/types/reports.ts
Normal file
28
packages/moderation/types/reports.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { Project, Report, Thread, User, Version, DelphiReport } from '@modrinth/utils'
|
||||||
|
|
||||||
|
export interface OwnershipTarget {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
avatar_url?: string
|
||||||
|
type: 'user' | 'organization'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtendedReport extends Report {
|
||||||
|
thread: Thread
|
||||||
|
reporter_user: User
|
||||||
|
project?: Project
|
||||||
|
user?: User
|
||||||
|
version?: Version
|
||||||
|
target?: OwnershipTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtendedDelphiReport extends DelphiReport {
|
||||||
|
target?: OwnershipTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportQuickReply {
|
||||||
|
label: string
|
||||||
|
message: string | ((report: ExtendedReport) => Promise<string> | string)
|
||||||
|
shouldShow?: (report: ExtendedReport) => boolean
|
||||||
|
private?: boolean
|
||||||
|
}
|
||||||
97
packages/ui/src/components/base/CollapsibleRegion.vue
Normal file
97
packages/ui/src/components/base/CollapsibleRegion.vue
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
|
||||||
|
:class="{ 'max-h-32': isCollapsed }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="px-4 pt-4"
|
||||||
|
:class="{
|
||||||
|
'content-disabled pb-16': isCollapsed,
|
||||||
|
'pb-4': !isCollapsed,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isCollapsed"
|
||||||
|
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
|
||||||
|
<ButtonStyled circular type="transparent">
|
||||||
|
<button class="flex items-center gap-1 text-xs" @click="toggleCollapsed">
|
||||||
|
<ExpandIcon v-if="isCollapsed" />
|
||||||
|
<CollapseIcon v-else />
|
||||||
|
{{ isCollapsed ? expandText : collapseText }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ButtonStyled from './ButtonStyled.vue'
|
||||||
|
import { ExpandIcon, CollapseIcon } from '@modrinth/assets'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
initiallyCollapsed?: boolean
|
||||||
|
expandText?: string
|
||||||
|
collapseText?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
initiallyCollapsed: true,
|
||||||
|
expandText: 'Expand',
|
||||||
|
collapseText: 'Collapse',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const isCollapsed = ref(props.initiallyCollapsed)
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCollapsed(value: boolean) {
|
||||||
|
isCollapsed.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
isCollapsed,
|
||||||
|
setCollapsed,
|
||||||
|
toggleCollapsed,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.content-disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
|
||||||
|
:deep(*) {
|
||||||
|
pointer-events: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-moz-user-select: none !important;
|
||||||
|
-ms-user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(button),
|
||||||
|
:deep(input),
|
||||||
|
:deep(textarea),
|
||||||
|
:deep(select),
|
||||||
|
:deep(a),
|
||||||
|
:deep([tabindex]) {
|
||||||
|
tabindex: -1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(*:focus) {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -163,7 +163,6 @@ const onFocus = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onBlur = (event) => {
|
const onBlur = (event) => {
|
||||||
console.log(event)
|
|
||||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||||
dropdownVisible.value = false
|
dropdownVisible.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export { default as Card } from './base/Card.vue'
|
|||||||
export { default as Checkbox } from './base/Checkbox.vue'
|
export { default as Checkbox } from './base/Checkbox.vue'
|
||||||
export { default as Chips } from './base/Chips.vue'
|
export { default as Chips } from './base/Chips.vue'
|
||||||
export { default as Collapsible } from './base/Collapsible.vue'
|
export { default as Collapsible } from './base/Collapsible.vue'
|
||||||
|
export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
|
||||||
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
|
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
|
||||||
export { default as CopyCode } from './base/CopyCode.vue'
|
export { default as CopyCode } from './base/CopyCode.vue'
|
||||||
export { default as DoubleIcon } from './base/DoubleIcon.vue'
|
export { default as DoubleIcon } from './base/DoubleIcon.vue'
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export type DonationPlatform =
|
|||||||
| { short: 'ko-fi'; name: 'Ko-fi' }
|
| { short: 'ko-fi'; name: 'Ko-fi' }
|
||||||
| { short: 'other'; name: 'Other' }
|
| { short: 'other'; name: 'Other' }
|
||||||
|
|
||||||
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'project'
|
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'plugin' | 'datapack' | 'project'
|
||||||
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
|
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
|
||||||
|
|
||||||
export type GameVersion = string
|
export type GameVersion = string
|
||||||
@ -65,7 +65,8 @@ export interface Project {
|
|||||||
client_side: Environment
|
client_side: Environment
|
||||||
server_side: Environment
|
server_side: Environment
|
||||||
|
|
||||||
team: ModrinthId
|
team?: ModrinthId
|
||||||
|
team_id: ModrinthId
|
||||||
thread_id: ModrinthId
|
thread_id: ModrinthId
|
||||||
organization: ModrinthId
|
organization: ModrinthId
|
||||||
|
|
||||||
@ -76,6 +77,7 @@ export interface Project {
|
|||||||
donation_urls: DonationLink<DonationPlatform>[]
|
donation_urls: DonationLink<DonationPlatform>[]
|
||||||
|
|
||||||
published: string
|
published: string
|
||||||
|
created?: string
|
||||||
updated: string
|
updated: string
|
||||||
approved: string
|
approved: string
|
||||||
queued: string
|
queued: string
|
||||||
@ -295,6 +297,60 @@ export type Report = {
|
|||||||
body: string
|
body: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Threads
|
||||||
|
export interface Thread {
|
||||||
|
id: string
|
||||||
|
type: ThreadType
|
||||||
|
project_id: string | null
|
||||||
|
report_id: string | null
|
||||||
|
messages: ThreadMessage[]
|
||||||
|
members: User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadType = 'project' | 'report' | 'direct_message'
|
||||||
|
|
||||||
|
export interface ThreadMessage {
|
||||||
|
id: string | null
|
||||||
|
author_id: string | null
|
||||||
|
body: MessageBody
|
||||||
|
created: string
|
||||||
|
hide_identity: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageBody =
|
||||||
|
| TextMessageBody
|
||||||
|
| StatusChangeMessageBody
|
||||||
|
| ThreadClosureMessageBody
|
||||||
|
| ThreadReopenMessageBody
|
||||||
|
| DeletedMessageBody
|
||||||
|
|
||||||
|
export interface TextMessageBody {
|
||||||
|
type: 'text'
|
||||||
|
body: string
|
||||||
|
private: boolean
|
||||||
|
replying_to: string | null
|
||||||
|
associated_images: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusChangeMessageBody {
|
||||||
|
type: 'status_change'
|
||||||
|
new_status: ProjectStatus
|
||||||
|
old_status: ProjectStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadClosureMessageBody {
|
||||||
|
type: 'thread_closure'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadReopenMessageBody {
|
||||||
|
type: 'thread_reopen'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeletedMessageBody {
|
||||||
|
type: 'deleted'
|
||||||
|
private: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Moderation
|
// Moderation
|
||||||
export interface ModerationModpackPermissionApprovalType {
|
export interface ModerationModpackPermissionApprovalType {
|
||||||
id:
|
id:
|
||||||
@ -379,3 +435,38 @@ export interface ModerationJudgement {
|
|||||||
export interface ModerationJudgements {
|
export interface ModerationJudgements {
|
||||||
[sha1: string]: ModerationJudgement
|
[sha1: string]: ModerationJudgement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delphi
|
||||||
|
export interface DelphiReport {
|
||||||
|
id: string
|
||||||
|
project: Project
|
||||||
|
version: Version
|
||||||
|
priority_score: number
|
||||||
|
detected_at: string
|
||||||
|
trace_type:
|
||||||
|
| 'reflection_indirection'
|
||||||
|
| 'xor_obfuscation'
|
||||||
|
| 'included_libraries'
|
||||||
|
| 'suspicious_binaries'
|
||||||
|
| 'corrupt_classes'
|
||||||
|
| 'suspicious_classes'
|
||||||
|
| 'url_usage'
|
||||||
|
| 'classloader_usage'
|
||||||
|
| 'processbuilder_usage'
|
||||||
|
| 'runtime_exec_usage'
|
||||||
|
| 'jni_usage'
|
||||||
|
| 'main_method'
|
||||||
|
| 'native_loading'
|
||||||
|
| 'malformed_jar'
|
||||||
|
| 'nested_jar_too_deep'
|
||||||
|
| 'failed_decompilation'
|
||||||
|
| 'analysis_failure'
|
||||||
|
| 'malware_easyforme'
|
||||||
|
| 'malware_simplyloader'
|
||||||
|
file_path: string
|
||||||
|
// pending = not reviewed yet.
|
||||||
|
// approved = approved as malicious, removed from modrinth
|
||||||
|
// rejected = not approved as malicious, remains on modrinth?
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|||||||
540
pnpm-lock.yaml
generated
540
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user