Projects overhaul for creators (#827)

* Projects page

* Continue work on bulk edit

* editLinks is now bulkEdit

* Bulk Edit Links completed

* Edit URL clear fields.

* Create project button + other bulk buttons.

* Pagination (w/o reactivity.)

* Apply suggestions from code review

Co-authored-by: triphora <emmaffle@modrinth.com>

* Sorting fixed, broken page count though?

* Only make editable projects selectable + remove delete button

* Shorthand

* Start using computed

* Fix pagination

* Add Pagination Switching

* Final Style Changes

* Cleanup

* Action Affects dropdown

* Switch to checkbox swizzle

* Projects dashboard, the most hellish thing I have ever worked on

* Rewrite project dashboard without tables

* why's that there

* Fix mod message icon

* New project settings page

* Remove extra slash

* Bulk project route and improve styling of links UI

* Remove beta label from Monetization

* Relevant page links in project settings

* Don't vertically center header rows

* Improve error messages, add remove project icon button, add saving feedback, begin project checklist, fix license settings

* Remove contextual link from project settings, disable WIP checklist

* Fix bulk edit

* Project checklist, add featured gallery image to project pages, fix random bugs

* Remove old check

* Remove icon border on grid mode and hide project status card when unnecessary

* Fix build

* Make checklist progress smaller and add collapsing

* Remove uneven gap on nav cards

* Improve wrapping of checklist

* Replace project settings header link with status

* Fix bugs + status stuff

* Fix warns + compile error

* Update wording

* Hide environment type nag for project types without it

* Make member dropdown match

Co-authored-by: mineblock11 <93472213+mineblock11@users.noreply.github.com>
Co-authored-by: triphora <emmaffle@modrinth.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
Prospector
2023-01-07 17:37:47 -08:00
committed by GitHub
parent 1d8c80c062
commit 212bb33142
48 changed files with 4085 additions and 1940 deletions

View File

@@ -2,14 +2,18 @@
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''}`"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${
noShadow ? 'no-shadow' : ''
}`"
:src="src"
:alt="alt"
:loading="loading"
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''}`"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${
noShadow ? 'no-shadow' : ''
}`"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@@ -52,6 +56,10 @@ export default {
type: Boolean,
default: false,
},
noShadow: {
type: Boolean,
default: false,
},
loading: {
type: String,
default: 'eager',
@@ -112,5 +120,9 @@ export default {
&.circle {
border-radius: 50%;
}
&.no-shadow {
box-shadow: none;
}
}
</style>

View File

@@ -1,28 +1,32 @@
<template>
<span :class="'version-badge ' + color + ' type--' + type">
<template v-if="color"
><span class="circle" /> {{ $capitalizeString(type) }}</template
>
<template v-else-if="type === 'admin'"
><ModrinthIcon /> Modrinth Team</template
>
<template v-else-if="type === 'moderator'"
><ModeratorIcon /> Moderator</template
>
<template v-if="color">
<span class="circle" /> {{ $capitalizeString(type) }}
</template>
<template v-else-if="type === 'admin'">
<ModrinthIcon /> Modrinth Team
</template>
<template v-else-if="type === 'moderator'">
<ModeratorIcon /> Moderator
</template>
<template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template>
<template v-else-if="type === 'approved'"><ListIcon /> Listed</template>
<template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
<template v-else-if="type === 'archived'"
><ArchiveIcon /> Archived</template
>
<template v-else-if="type === 'archived'">
<ArchiveIcon /> Archived
</template>
<template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template>
<template v-else-if="type === 'processing'"
><ProcessingIcon /> Under review</template
>
<template v-else
><span class="circle" /> {{ $capitalizeString(type) }}</template
>
<template v-else-if="type === 'processing'">
<ProcessingIcon /> Under review
</template>
<template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template>
<template v-else-if="type === 'pending'">
<ProcessingIcon /> Pending
</template>
<template v-else>
<span class="circle" /> {{ $capitalizeString(type) }}
</template>
</span>
</template>
@@ -36,6 +40,7 @@ import DraftIcon from '~/assets/images/utils/file-text.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import ArchiveIcon from '~/assets/images/utils/archive.svg?inline'
import ProcessingIcon from '~/assets/images/utils/updated.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
export default {
name: 'Badge',
@@ -49,6 +54,7 @@ export default {
CrossIcon,
ArchiveIcon,
ProcessingIcon,
CheckIcon,
},
props: {
type: {
@@ -90,12 +96,14 @@ export default {
--badge-color: var(--color-special-red);
}
&.type--pending,
&.type--moderator,
&.type--processing,
&.orange {
--badge-color: var(--color-special-orange);
}
&.type--accepted,
&.type--admin,
&.green {
--badge-color: var(--color-special-green);

View File

@@ -73,7 +73,7 @@ export default {
p {
user-select: none;
padding: 0.2rem 0rem;
padding: 0.2rem 0;
margin: 0;
}

View File

@@ -0,0 +1,116 @@
<template>
<span v-if="typeOnly" class="environment">
<InfoIcon aria-hidden="true" />
A {{ type }}
</span>
<span
v-else-if="
!['resourcepack', 'shader'].includes(type) &&
!(type === 'plugin' && search) &&
!categories.some((x) => $tag.loaderData.dataPackLoaders.includes(x))
"
class="environment"
>
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
<GlobeIcon aria-hidden="true" />
Client or server
</template>
<template
v-else-if="clientSide === 'required' && serverSide === 'required'"
>
<GlobeIcon aria-hidden="true" />
Client and server
</template>
<template
v-else-if="
(clientSide === 'optional' || clientSide === 'required') &&
(serverSide === 'optional' || serverSide === 'unsupported')
"
>
<ClientIcon aria-hidden="true" />
Client
</template>
<template
v-else-if="
(serverSide === 'optional' || serverSide === 'required') &&
(clientSide === 'optional' || clientSide === 'unsupported')
"
>
<ServerIcon aria-hidden="true" />
Server
</template>
<template
v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'"
>
<GlobeIcon aria-hidden="true" />
Unsupported
</template>
<template v-else-if="alwaysShow">
<InfoIcon aria-hidden="true" />
A {{ type }}
</template>
</span>
</template>
<script>
import InfoIcon from '~/assets/images/utils/info.svg?inline'
import ClientIcon from '~/assets/images/utils/client.svg?inline'
import GlobeIcon from '~/assets/images/utils/globe.svg?inline'
import ServerIcon from '~/assets/images/utils/server.svg?inline'
export default {
name: 'EnvironmentIndicator',
components: {
InfoIcon,
ClientIcon,
ServerIcon,
GlobeIcon,
},
props: {
type: {
type: String,
default: 'mod',
},
serverSide: {
type: String,
required: false,
default: '',
},
clientSide: {
type: String,
required: false,
default: '',
},
typeOnly: {
type: Boolean,
required: false,
default: false,
},
alwaysShow: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
categories: {
type: Array,
required: false,
default() {
return []
},
},
},
}
</script>
<style lang="scss" scoped>
.environment {
display: flex;
color: var(--color-text) !important;
font-weight: bold;
svg {
margin-right: 0.2rem;
}
}
</style>

View File

@@ -85,7 +85,6 @@ export default {
<style lang="scss" scoped>
label {
flex-direction: unset;
margin-bottom: 0;
max-height: unset;
svg {

View File

@@ -9,15 +9,17 @@
@click="hide"
/>
<div class="modal-body" :class="{ shown: shown }">
<div v-if="header" class="header">
<h1>{{ header }}</h1>
<button class="iconified-button icon-only transparent" @click="hide">
<CrossIcon />
</button>
</div>
<div class="content">
<slot></slot>
</div>
<template v-if="shown">
<div v-if="header" class="header">
<h1>{{ header }}</h1>
<button class="iconified-button icon-only transparent" @click="hide">
<CrossIcon />
</button>
</div>
<div class="content">
<slot></slot>
</div>
</template>
</div>
</div>
</template>

View File

@@ -1,6 +1,8 @@
<template>
<nav class="navigation">
<slot />
<nav>
<ul>
<slot />
</ul>
</nav>
</template>
@@ -11,10 +13,18 @@ export default {
</script>
<style lang="scss" scoped>
.navigation {
ul {
display: flex;
flex-direction: column;
grid-gap: var(--spacing-card-xs);
flex-wrap: wrap;
list-style-type: none;
margin: 0;
padding: 0;
}
li {
display: unset;
text-align: unset;
}
</style>

View File

@@ -1,21 +1,44 @@
<template>
<NuxtLink class="nav-link button-base" :to="link">
<NuxtLink v-if="link !== null" class="nav-link button-base" :to="link">
<div class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
<span v-if="chevron" class="chevron"><ChevronRightIcon /></span>
</div>
</NuxtLink>
<button
v-else-if="action"
class="nav-link button-base"
:class="{ 'danger-button': danger }"
@click="action"
>
<span class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
</span>
</button>
<span v-else>i forgor 💀</span>
</template>
<script>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline'
export default {
name: 'NavStackItem',
components: {
ChevronRightIcon,
},
props: {
link: {
required: true,
default: null,
type: String,
},
action: {
default: null,
type: Function,
},
label: {
required: true,
type: String,
@@ -24,6 +47,14 @@ export default {
default: false,
type: Boolean,
},
chevron: {
default: false,
type: Boolean,
},
danger: {
default: false,
type: Boolean,
},
},
}
</script>
@@ -31,12 +62,20 @@ export default {
<style lang="scss" scoped>
.nav-link {
font-weight: var(--font-weight-bold);
color: var(--color-text);
background-color: transparent;
color: var(--text-color);
position: relative;
display: flex;
flex-direction: row;
gap: 0.25rem;
box-shadow: none;
padding: 0;
width: 100%;
:where(.nav-link) {
--text-color: var(--color-text);
--background-color: var(--color-raised-bg);
}
.nav-content {
box-sizing: border-box;
@@ -46,7 +85,7 @@ export default {
align-items: center;
gap: 0.4rem;
flex-grow: 1;
background-color: var(--color-raised-bg);
background-color: var(--background-color);
}
&.nuxt-link-exact-active {
@@ -60,5 +99,9 @@ export default {
.beta-badge {
margin: 0;
}
.chevron {
margin-left: auto;
}
}
</style>

View File

@@ -9,7 +9,7 @@
tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
>
<Avatar :src="iconUrl" :alt="name" size="md" loading="lazy" />
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
</nuxt-link>
<nuxt-link
class="gallery"
@@ -49,59 +49,14 @@
:type="type"
class="tags"
>
<span v-if="moderation" class="environment">
<InfoIcon aria-hidden="true" />
A {{ projectTypeDisplay }}
</span>
<span
v-else-if="
!['resourcepack', 'shader'].includes(type) &&
!(projectTypeDisplay === 'plugin' && search) &&
!categories.some((x) => $tag.loaderData.dataPackLoaders.includes(x))
"
class="environment"
>
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
<GlobeIcon aria-hidden="true" />
Client or server
</template>
<template
v-else-if="clientSide === 'required' && serverSide === 'required'"
>
<GlobeIcon aria-hidden="true" />
Client and server
</template>
<template
v-else-if="
(clientSide === 'optional' || clientSide === 'required') &&
(serverSide === 'optional' || serverSide === 'unsupported')
"
>
<ClientIcon aria-hidden="true" />
Client
</template>
<template
v-else-if="
(serverSide === 'optional' || serverSide === 'required') &&
(clientSide === 'optional' || clientSide === 'unsupported')
"
>
<ServerIcon aria-hidden="true" />
Server
</template>
<template
v-else-if="
serverSide === 'unsupported' && clientSide === 'unsupported'
"
>
<GlobeIcon aria-hidden="true" />
Unsupported
</template>
<template v-else-if="moderation">
<InfoIcon aria-hidden="true" />
A {{ projectTypeDisplay }}
</template>
</span>
<EnvironmentIndicator
:type-only="moderation"
:client-side="clientSide"
:server-side="serverSide"
:type="projectTypeDisplay"
:search="search"
:categories="categories"
/>
</Categories>
<div class="stats">
<div v-if="downloads" class="stat">
@@ -150,11 +105,8 @@
<script>
import Categories from '~/components/ui/search/Categories'
import Badge from '~/components/ui/Badge'
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator'
import InfoIcon from '~/assets/images/utils/info.svg?inline'
import ClientIcon from '~/assets/images/utils/client.svg?inline'
import GlobeIcon from '~/assets/images/utils/globe.svg?inline'
import ServerIcon from '~/assets/images/utils/server.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import EditIcon from '~/assets/images/utils/updated.svg?inline'
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
@@ -164,13 +116,10 @@ import Avatar from '~/components/ui/Avatar'
export default {
name: 'ProjectCard',
components: {
EnvironmentIndicator,
Avatar,
Categories,
Badge,
InfoIcon,
ClientIcon,
ServerIcon,
GlobeIcon,
CalendarIcon,
EditIcon,
DownloadIcon,
@@ -364,7 +313,7 @@ export default {
img,
svg {
border-radius: var(--size-rounded-lg);
border: 0.25rem solid var(--color-raised-bg);
border: 4px solid var(--color-raised-bg);
border-bottom: none;
}
}
@@ -427,6 +376,11 @@ export default {
.icon {
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
img,
svg {
border: none;
}
}
.title {

View File

@@ -0,0 +1,440 @@
<template>
<div
v-if="
$auth.user &&
currentMember &&
nags.filter((x) => x.condition).length > 0 &&
project.status === 'draft'
"
class="author-actions universal-card"
>
<div class="header__row">
<div class="header__title">
<h2>Publishing checklist</h2>
<div class="checklist">
<span class="checklist__title">Progress:</span>
<div class="checklist__items">
<div
v-for="nag in nags"
:key="`checklist-${nag.id}`"
v-tooltip="nag.title"
class="circle"
:class="'circle ' + (!nag.condition ? 'done ' : '') + nag.status"
>
<CheckIcon v-if="!nag.condition" />
<RequiredIcon v-else-if="nag.status === 'required'" />
<SuggestionIcon v-else-if="nag.status === 'suggestion'" />
<ModerationIcon v-else-if="nag.status === 'review'" />
</div>
</div>
</div>
</div>
<div class="input-group">
<button
class="square-button"
:class="{ 'not-collapsed': !collapsed }"
@click="toggleCollapsed()"
>
<DropdownIcon />
</button>
</div>
</div>
<div v-if="!collapsed" class="grid-display width-16">
<div
v-for="nag in nags.filter((x) => x.condition)"
:key="nag.id"
class="grid-display__item"
>
<span class="label">
<RequiredIcon
v-if="nag.status === 'required'"
v-tooltip="'Required'"
:class="nag.status"
/>
<SuggestionIcon
v-else-if="nag.status === 'suggestion'"
v-tooltip="'Suggestion'"
:class="nag.status"
/>
<ModerationIcon
v-else-if="nag.status === 'review'"
v-tooltip="'Review'"
:class="nag.status"
/>{{ nag.title }}</span
>
{{ nag.description }}
<NuxtLink
v-if="nag.link"
:class="{ invisible: nag.link.hide }"
class="goto-link"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/${nag.link.path}`"
>
{{ nag.link.title }}
<ChevronRightIcon
class="featured-header-chevron"
aria-hidden="true"
/>
</NuxtLink>
<button
v-else-if="nag.action"
class="iconified-button moderation-button"
:disabled="nag.action.disabled()"
@click="nag.action.onClick"
>
<SendIcon />
{{ nag.action.title }}
</button>
</div>
</div>
</div>
</template>
<script>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import RequiredIcon from '~/assets/images/utils/asterisk.svg?inline'
import SuggestionIcon from '~/assets/images/utils/lightbulb.svg?inline'
import ModerationIcon from '~/assets/images/sidebar/admin.svg?inline'
import SendIcon from '~/assets/images/utils/send.svg?inline'
export default {
name: 'ProjectPublishingChecklist',
components: {
ChevronRightIcon,
DropdownIcon,
CheckIcon,
RequiredIcon,
SuggestionIcon,
ModerationIcon,
SendIcon,
},
props: {
project: {
type: Object,
required: true,
},
versions: {
type: Array,
required: true,
},
currentMember: {
type: Object,
required: true,
},
isSettings: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
routeName: {
type: String,
default: '',
},
setProcessing: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'setProcessing function not found',
type: 'error',
})
}
},
},
toggleCollapsed: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'toggleCollapsed function not found',
type: 'error',
})
}
},
},
},
computed: {
featuredGalleryImage() {
return this.project.gallery.find((img) => img.featured)
},
nags() {
return [
{
condition:
this.project.body === '' ||
this.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: this.routeName === 'type-id-settings-description',
},
},
{
condition: !this.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: this.routeName === 'type-id-settings',
},
},
{
condition: !this.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: this.routeName === 'type-id-gallery',
},
},
{
condition: this.versions.length < 1,
title: 'Upload a version',
id: 'upload-version',
description:
'At least one version is required for a project to be submitted for review.',
status: 'required',
link: {
path: 'versions',
title: 'Visit versions page',
hide: this.routeName === 'type-id-versions',
},
},
{
condition: this.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: this.routeName === 'type-id-settings-tags',
},
},
{
hide:
this.project.project_type === 'resourcepack' ||
this.project.project_type === 'plugin' ||
this.project.project_type === 'shader' ||
this.project.project_type === 'datapack',
condition:
this.project.client_side === 'unknown' ||
this.project.server_side === 'unknown',
title: 'Select supported environments',
id: 'select-environments',
description: `Select if the ${this.$formatProjectType(
this.project.project_type
).toLowerCase()} functions on the client-side and/or server-side.`,
status: 'required',
link: {
path: 'settings',
title: 'Visit general settings',
hide: this.routeName === 'type-id-settings',
},
},
{
condition: this.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: this.submitForReview,
title: 'Submit for review',
disabled: () =>
this.nags.filter((x) => x.condition && x.status === 'required')
.length > 0,
},
},
]
.filter((x) => !x.hide)
.sort((a, b) =>
this.sortByTrue(
!a.condition,
!b.condition,
this.sortByTrue(
a.status === 'required',
b.status === 'required',
this.sortByFalse(a.status === 'review', b.status === 'review')
)
)
)
},
},
methods: {
sortByTrue(a, b, ifEqual = 0) {
if (a === b) {
return ifEqual
} else if (a) {
return -1
} else {
return 1
}
},
sortByFalse(a, b, ifEqual = 0) {
if (a === b) {
return ifEqual
} else if (b) {
return -1
} else {
return 1
}
},
async submitForReview() {
if (
this.nags.filter((x) => x.condition && x.status === 'required')
.length === 0
) {
await this.setProcessing()
}
},
},
}
</script>
<style lang="scss" scoped>
.author-actions {
&: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-special-red);
}
.suggestion {
color: var(--color-special-purple);
}
.review {
color: var(--color-special-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-special-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-special-red);
}
&.suggestion {
--content-color: var(--color-special-purple);
}
&.review {
--content-color: var(--color-special-orange);
}
&.done {
--background-color: var(--color-special-green);
--content-color: var(--color-brand-inverted);
}
}
}
}
</style>