feat: start on frontend impl

This commit is contained in:
Calum 2025-07-11 16:52:49 +01:00
parent b98c1fe7b8
commit bcfceecf1b
2 changed files with 214 additions and 426 deletions

View File

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

View File

@ -1360,6 +1360,8 @@ const currentMember = computed(() => {
}; };
} }
console.log("Current member:", val);
return val; return val;
}); });