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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 4085 additions and 1940 deletions

View File

@ -0,0 +1,6 @@
<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">
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="15" y1="12" x2="3" y2="12"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@ -0,0 +1,6 @@
<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">
<path d="M12 6v12"></path>
<path d="M17.196 9 6.804 15"></path>
<path d="m6.804 9 10.392 6"></path>
</svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@ -0,0 +1,4 @@
<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">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -0,0 +1,7 @@
<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">
<circle cx="8" cy="8" r="6"></circle>
<path d="M18.09 10.37A6 6 0 1 1 10.34 18"></path>
<path d="M7 6h1v4"></path>
<path d="m16.71 13.88.7.71-2.82 2.82"></path>
</svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@ -0,0 +1,5 @@
<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">
<circle cx="12" cy="12" r="10"></circle>
<path d="M15 9.354a4 4 0 1 0 0 5.292"></path>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@ -0,0 +1,6 @@
<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">
<line x1="9" y1="18" x2="15" y2="18"></line>
<line x1="10" y1="22" x2="14" y2="22"></line>
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"></path>
</svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@ -0,0 +1,5 @@
<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">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@ -0,0 +1,5 @@
<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">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>

After

Width:  |  Height:  |  Size: 293 B

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"> <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">
<path <path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"></path>
d="M7 7H7.01M7 3H12C12.5119 2.99999 13.0237 3.19525 13.4142 3.58579L20.4143 10.5858C21.1953 11.3668 21.1953 12.6332 20.4143 13.4142L13.4142 20.4142C12.6332 21.1953 11.3668 21.1953 10.5858 20.4142L3.58579 13.4142C3.19526 13.0237 3 12.5118 3 12V7C3 4.79086 4.79086 3 7 3Z" <path d="M7 7h.01"></path>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 474 B

After

Width:  |  Height:  |  Size: 320 B

View File

@ -0,0 +1,6 @@
<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">
<path d="M9 5H2v7l6.29 6.29c.94.94 2.48.94 3.42 0l3.58-3.58c.94-.94.94-2.48 0-3.42L9 5Z"></path>
<path d="M6 9.01V9"></path>
<path d="m15 5 6.3 6.3a2.4 2.4 0 0 1 0 3.4L17 19"></path>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1,7 @@
<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">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<line x1="19" y1="8" x2="19" y2="14"></line>
<line x1="22" y1="11" x2="16" y2="11"></line>
</svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@ -0,0 +1,7 @@
<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">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<line x1="17" y1="8" x2="22" y2="13"></line>
<line x1="22" y1="8" x2="17" y2="13"></line>
</svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -1,3 +1,478 @@
/*
Base components
*/
.multiselect--above .multiselect__content-wrapper {
border-top: none !important;
border-top-left-radius: var(--size-rounded-card) !important;
border-top-right-radius: var(--size-rounded-card) !important;
}
.known-error .multiselect__tags {
border-color: var(--color-special-red) !important;
background-color: var(--color-warning-bg) !important;
&::placeholder {
color: var(--color-warning-text);
}
}
.multiselect {
color: var(--color-text) !important;
outline: 2px solid transparent;
input {
background: transparent;
box-shadow: none;
}
input::placeholder {
color: var(--color-text);
}
.multiselect__tags {
border-radius: var(--size-rounded-sm);
background: var(--color-dropdown-bg);
box-shadow: var(--shadow-inset-sm);
border: none;
cursor: pointer;
padding-left: 7px;
padding-top: 10px;
transition: background-color 0.1s ease-in-out;
&:active {
background: var(--color-button-bg-hover);
.multiselect__spinner {
background: var(--color-button-bg-hover);
}
}
.multiselect__single {
background: transparent;
}
.multiselect__tag {
border-radius: var(--size-rounded-sm);
color: var(--color-text-dark);
background: transparent;
border: 2px solid var(--color-brand);
}
.multiselect__tag-icon {
background: transparent;
&:after {
color: var(--color-text-dark);
}
}
.multiselect__placeholder {
color: var(--color-button-text);
margin-left: 8px;
opacity: 0.6;
font-size: 16px;
line-height: 20px;
}
}
.multiselect__content-wrapper {
background: var(--color-dropdown-bg);
border: none;
overflow-x: hidden;
border-bottom-left-radius: var(--size-rounded-sm);
border-bottom-right-radius: var(--size-rounded-sm);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
.multiselect__element {
.multiselect__option--highlight {
background: var(--color-button-bg-active);
color: var(--color-text-dark);
}
.multiselect__option--selected {
background: var(--color-brand);
font-weight: bold;
color: var(--color-brand-inverted);
}
}
}
.multiselect__spinner {
background: var(--color-dropdown-bg);
&:active {
background: var(--color-button-bg-hover);
}
}
&.multiselect--disabled {
background: none;
.multiselect__current,
.multiselect__select {
background: none;
}
}
}
.grid-display {
display: grid;
grid-gap: var(--spacing-card-md);
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
.grid-display__item {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: flex-start;
background-color: var(--color-bg);
border-radius: var(--size-rounded-card);
padding: var(--spacing-card-lg);
gap: var(--spacing-card-md);
.label {
color: var(--color-heading);
font-weight: bold;
font-size: 1rem;
}
.value {
color: var(--color-text-dark);
font-weight: bold;
font-size: 2rem;
}
.goto-link {
margin-top: auto;
}
}
&.width-12 {
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
&.width-16 {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
}
/*
Cards and body styling
*/
.base-card {
@extend .padding-lg;
position: relative;
min-height: var(--font-size-2xl);
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
margin-bottom: var(--spacing-card-md);
outline: 2px solid transparent;
box-shadow: var(--shadow-card);
.card__overlay {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
grid-gap: 0.5rem;
z-index: 2;
}
&.warning {
border-left: 0.5rem solid var(--color-banner-side);
padding: 1.5rem;
line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0;
a {
/* Uses active color to increase contrast */
color: var(--color-link-active);
text-decoration: underline;
}
}
}
.universal-labels {
label,
.label {
:where(.label__title) {
display: block;
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
// Same styling as h3
color: var(--color-text-dark);
font-size: 1.17rem;
font-weight: bold;
.required {
color: var(--color-special-red);
}
&.size-card-header {
font-size: var(--font-size-xl);
margin-bottom: 1rem;
}
}
:where(.label__description) {
display: block;
margin-block-end: var(--spacing-card-sm);
.label__subdescription {
display: block;
margin-block-start: var(--spacing-card-md);
}
}
:where(h1, h2, h3, h4) {
margin-block: 0;
}
}
}
.padding-lg {
padding: var(--spacing-card-lg);
}
.padding-bg {
padding: var(--spacing-card-bg);
}
.padding-md {
padding: var(--spacing-card-md);
}
.padding-sm {
padding: var(--spacing-card-sm);
}
.padding-0 {
padding: 0;
}
.padding-block-lg {
padding-block: var(--spacing-card-lg);
}
.padding-block-bg {
padding-block: var(--spacing-card-bg);
}
.padding-block-md {
padding-block: var(--spacing-card-md);
}
.padding-block-sm {
padding-block: var(--spacing-card-sm);
}
.padding-block-0 {
padding-block: 0;
}
.padding-inline-lg {
padding-inline: var(--spacing-card-lg);
}
.padding-inline-bg {
padding-inline: var(--spacing-card-bg);
}
.padding-inline-md {
padding-inline: var(--spacing-card-md);
}
.padding-inline-sm {
padding-inline: var(--spacing-card-sm);
}
.padding-inline-0 {
padding-inline: 0;
}
.universal-body {
@extend .universal-labels;
.multiselect {
width: 15rem;
}
> :where(input + *, .input-group + *, .textarea-wrapper + *, .chips
+ *, .resizable-textarea-wrapper + *, .input-div + *) {
margin-block-start: var(--spacing-card-md);
}
:where(button, .button, .iconified-button) {
width: fit-content;
}
.input-group {
input {
width: auto;
flex-basis: 0;
}
}
:where(input) {
box-sizing: border-box;
max-height: 40px;
width: 24rem;
flex-basis: 24rem;
&:not(.stylized-toggle) {
max-width: 100%;
}
}
:where(.adjacent-input, &.adjacent-input) {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-card-sm);
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
.iconified-button,
.input-group {
flex-shrink: 0;
}
input {
flex-shrink: 1;
}
> :first-child {
flex-shrink: 2;
flex-grow: 1;
flex-basis: min-content;
}
label,
.label {
.label__title {
margin-block: 0;
}
.label__description {
margin-block-end: 0;
}
.label__description:not(:first-child) {
margin-top: var(--spacing-card-sm);
}
}
@media screen and (max-width: 750px) {
&:not(&.small) {
flex-direction: column;
align-items: start;
.stylized-toggle {
flex-basis: 0;
}
}
}
}
h1 {
display: flex;
align-items: center;
}
> :first-child {
margin-block-start: 0;
}
> :last-child {
margin-block-end: 0;
}
:where(.header__row) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--spacing-card-sm);
* {
flex-shrink: 0;
}
.header__title {
margin: 0;
flex-grow: 1;
}
&:not(:last-child) {
margin-bottom: var(--spacing-card-md);
}
}
> .label:first-child :where(> :first-child, .label__title),
> label:first-child :where(> :first-child, .label__title),
> .adjacent-input:first-child :where(> :first-child, .label__title) {
margin-block-start: 0;
}
}
.universal-card {
@extend .base-card;
@extend .universal-body;
}
.universal-modal {
@extend .universal-body;
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
> p:first-child {
margin-top: 0;
}
@media screen and (max-width: 750px) {
.adjacent-input,
&.adjacent-input &:not(&.small) {
flex-direction: row;
align-items: center;
}
}
@media screen and (max-width: calc(600px + 2rem)) {
.adjacent-input,
&.adjacent-input &:not(&.small) {
flex-direction: column;
align-items: start;
}
}
}
.navigation-card {
@extend .base-card;
@extend .padding-inline-lg;
@extend .padding-block-md;
align-items: center;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
row-gap: 0.5rem;
}
/*
Other
*/
// Here lies 𝖄𝖊 𝕸𝖆𝖗𝖌𝖎𝖓 𝕸𝖆𝖌𝖎𝖈 // Here lies 𝖄𝖊 𝕸𝖆𝖗𝖌𝖎𝖓 𝕸𝖆𝖌𝖎𝖈
// which allows to have just one wrapper div // which allows to have just one wrapper div
.iconified-input { .iconified-input {
@ -551,6 +1026,11 @@ tr.button-transparent {
--text-color: var(--color-brand-inverted); --text-color: var(--color-brand-inverted);
} }
.moderation-button {
--background-color: var(--color-special-orange);
--text-color: var(--color-brand-inverted);
}
.brand-button { .brand-button {
--background-color: var(--color-brand); --background-color: var(--color-brand);
--text-color: var(--color-brand-inverted); --text-color: var(--color-brand-inverted);
@ -840,22 +1320,23 @@ tr.button-transparent {
} }
.vue-notification { .vue-notification {
background: #44a4fc; background: var(--color-special-blue);
border-left: 5px solid #44a4fc; border-left: 5px solid var(--color-special-blue);
color: var(--color-brand-inverted);
&.success { &.success {
background: #68cd86; background: var(--color-special-green);
border-left-color: #68cd86; border-left-color: var(--color-special-green);
} }
&.warn { &.warn {
background: #ffb648; background: var(--color-special-orange);
border-left-color: #ffb648; border-left-color: var(--color-special-orange);
} }
&.error { &.error {
background: #e54d42; background: var(--color-special-red);
border-left-color: #e54d42; border-left-color: var(--color-special-red);
} }
} }
@ -977,214 +1458,6 @@ h3 {
} }
} }
.base-card {
@extend .padding-lg;
position: relative;
min-height: var(--font-size-2xl);
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
margin-bottom: var(--spacing-card-md);
outline: 2px solid transparent;
box-shadow: var(--shadow-card);
.card__overlay {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
grid-gap: 0.5rem;
z-index: 2;
}
&.warning {
border-left: 0.5rem solid var(--color-banner-side);
padding: 1.5rem;
line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0;
a {
/* Uses active color to increase contrast */
color: var(--color-link-active);
text-decoration: underline;
}
}
}
.universal-labels {
label,
.label {
.label__title {
display: block;
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
// Same styling as h3
color: var(--color-text-dark);
font-size: 1.17em;
font-weight: bold;
.required {
color: var(--color-special-red);
}
}
.label__description {
display: block;
margin-block-end: var(--spacing-card-sm);
.label__subdescription {
display: block;
margin-block-start: var(--spacing-card-md);
}
}
}
}
.padding-lg {
padding: var(--spacing-card-lg);
}
.padding-bg {
padding: var(--spacing-card-bg);
}
.padding-md {
padding: var(--spacing-card-md);
}
.padding-sm {
padding: var(--spacing-card-sm);
}
.padding-block-lg {
padding-block: var(--spacing-card-lg);
}
.padding-block-bg {
padding-block: var(--spacing-card-bg);
}
.padding-block-md {
padding-block: var(--spacing-card-md);
}
.padding-block-sm {
padding-block: var(--spacing-card-sm);
}
.padding-inline-lg {
padding-inline: var(--spacing-card-lg);
}
.padding-inline-bg {
padding-inline: var(--spacing-card-bg);
}
.padding-inline-md {
padding-inline: var(--spacing-card-md);
}
.padding-inline-sm {
padding-inline: var(--spacing-card-sm);
}
.universal-card {
@extend .base-card;
@extend .universal-labels;
.multiselect {
width: 15rem;
}
> :where(input + *, .input-group + *) {
margin-block-start: var(--spacing-card-md);
}
.input-group {
.multiselect,
input {
width: auto;
flex-basis: 0;
}
}
button,
.button,
.iconified-button {
width: fit-content;
}
input {
box-sizing: border-box;
max-height: 40px;
width: 24rem;
flex-basis: 24rem;
&:not(.stylized-toggle) {
max-width: 100%;
}
}
.adjacent-input,
&.adjacent-input {
display: flex;
flex-direction: row;
align-items: center;
.iconified-button,
.input-group {
flex-shrink: 0;
}
input {
flex-shrink: 1;
}
> :first-child {
flex-shrink: 2;
flex-grow: 1;
margin-right: var(--spacing-card-md);
}
@media screen and (max-width: 750px) {
&:not(&.small) {
flex-direction: column;
align-items: start;
margin-bottom: var(--spacing-card-sm);
.stylized-toggle {
flex-basis: 0;
}
> :first-child {
margin-right: 0;
margin-bottom: var(--spacing-card-md);
}
}
}
}
h1 {
display: flex;
align-items: center;
}
> :first-child {
margin-block-start: 0;
}
> :last-child {
margin-block-end: 0;
}
}
.push-right { .push-right {
margin-left: auto; margin-left: auto;
margin-right: 0; margin-right: 0;
@ -1206,31 +1479,6 @@ button {
} }
} }
.header-card {
@extend .universal-card;
.header__row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-card-sm);
* {
flex-shrink: 0;
}
.header__title {
margin: 0;
flex-grow: 1;
}
&:not(:last-child) {
margin-bottom: var(--spacing-card-md);
}
}
}
.legacy-label-styles { .legacy-label-styles {
label { label {
display: flex; display: flex;
@ -1298,6 +1546,32 @@ button {
input { input {
flex-shrink: 2; flex-shrink: 2;
} }
&.shrink-first {
:first-child {
flex-shrink: 2;
flex-grow: 1;
flex-basis: min-content;
}
:not(:first-child) {
flex-shrink: 1;
}
}
}
.input-stack {
display: flex;
flex-direction: column;
> * {
margin-bottom: var(--spacing-card-sm);
}
> .multiselect {
width: unset;
height: inherit;
}
} }
.text-input-wrapper { .text-input-wrapper {
@ -1414,6 +1688,14 @@ button {
} }
} }
.wrap-as-needed {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
-webkit-hyphens: auto;
hyphens: auto;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 0; width: 0;

View File

@ -76,7 +76,7 @@ html {
--color-hr: var(--color-text); --color-hr: var(--color-text);
--color-table-border: #dfe2e5; --color-table-border: #dfe2e5;
--color-table-alternate-row: #f6f8fa; --color-table-alternate-row: #f2f4f7;
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1); --shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05); --shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
@ -204,7 +204,7 @@ html {
--color-hr: var(--color-text); --color-hr: var(--color-text);
--color-table-border: #4f5864; --color-table-border: #4f5864;
--color-table-alternate-row: #262a30; --color-table-alternate-row: #202228;
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1); --shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05); --shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);

View File

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

View File

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

View File

@ -73,7 +73,7 @@ export default {
p { p {
user-select: none; user-select: none;
padding: 0.2rem 0rem; padding: 0.2rem 0;
margin: 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> <style lang="scss" scoped>
label { label {
flex-direction: unset; flex-direction: unset;
margin-bottom: 0;
max-height: unset; max-height: unset;
svg { svg {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,111 @@
<template> <template>
<div> <div v-if="isSettings" class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<div class="settings-header">
<Avatar
:src="project.icon_url"
:alt="project.title"
size="sm"
class="settings-header__icon"
/>
<div class="settings-header__text">
<h1 class="wrap-as-needed">{{ project.title }}</h1>
<Badge :type="project.status" />
</div>
</div>
<h2>Project settings</h2>
<NavStack>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/settings`"
label="General"
>
<SettingsIcon />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/settings/tags`"
label="Tags"
>
<CategoriesIcon />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/settings/description`"
label="Description"
>
<DescriptionIcon />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/settings/license`"
label="License"
>
<LicenseIcon />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/settings/links`"
label="Links"
>
<LinksIcon />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/settings/members`"
label="Members"
>
<UsersIcon />
</NavStackItem>
<h3>Relevant pages</h3>
<NavStackItem
:link="`/${project.project_type}/${project.slug}`"
label="View project"
chevron
>
<EyeIcon />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/gallery`"
label="Gallery"
chevron
>
<GalleryIcon />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug}/versions`"
label="Versions"
chevron
>
<VersionIcon />
</NavStackItem>
<NavStackItem link="/dashboard/projects" label="All projects" chevron>
<SettingsIcon />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<ProjectPublishingChecklist
:project="project"
:versions="versions"
:current-member="currentMember"
:is-settings="isSettings"
:route-name="routeName"
:set-processing="setProcessing"
:collapsed="collapsedChecklist"
:toggle-collapsed="toggleChecklistCollapse"
/>
<NuxtChild
:project.sync="project"
:versions.sync="versions"
:featured-versions.sync="featuredVersions"
:members.sync="members"
:current-member="currentMember"
:all-members.sync="allMembers"
:dependencies.sync="dependencies"
:patch-project="patchProject"
:patch-icon="patchIcon"
:update-icon="updateIcon"
/>
</div>
</div>
<div v-else>
<Modal <Modal
ref="modal_license" ref="modal_license"
:header="project.license.name ? project.license.name : 'License'" :header="project.license.name ? project.license.name : 'License'"
@ -21,71 +127,49 @@
}" }"
> >
<div class="normal-page__sidebar"> <div class="normal-page__sidebar">
<div class="header card"> <div
class="header project__header base-card padding-0"
:class="{ 'has-featured-image': featuredGalleryImage }"
>
<nuxt-link <nuxt-link
class="project__gallery"
tabindex="-1"
:to=" :to="
'/' + '/' +
project.project_type + project.project_type +
'/' + '/' +
(project.slug ? project.slug : project.id) (project.slug ? project.slug : project.id) +
'/gallery'
" "
> >
<Avatar :src="project.icon_url" :alt="project.title" size="md" /> <img
</nuxt-link> v-if="featuredGalleryImage"
<nuxt-link :src="featuredGalleryImage.url"
:to=" :alt="
'/' + featuredGalleryImage.description
project.project_type + ? featuredGalleryImage.description
'/' + : featuredGalleryImage.title
(project.slug ? project.slug : project.id)
" "
> />
<h1 class="title">{{ project.title }}</h1>
</nuxt-link> </nuxt-link>
<div <div
v-if=" class="project__header__content universal-card full-width-inputs"
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
> >
<div <Avatar
v-if=" :src="project.icon_url"
project.client_side === 'optional' && :alt="project.title"
project.server_side === 'optional' size="md"
" class="project__icon"
class="side-descriptor" no-shadow
> />
<InfoIcon aria-hidden="true" /> <h1 class="title">
Universal {{ projectTypeDisplay }} {{ project.title }}
</div> </h1>
<div <Badge
v-else-if=" v-if="$auth.user && currentMember"
(project.client_side === 'optional' || :type="project.status"
project.client_side === 'required') && class="status-badge"
(project.server_side === 'optional' || />
project.server_side === 'unsupported')
"
class="side-descriptor"
>
<InfoIcon aria-hidden="true" />
Client {{ projectTypeDisplay }}
</div>
<div
v-else-if="
(project.server_side === 'optional' ||
project.server_side === 'required') &&
(project.client_side === 'optional' ||
project.client_side === 'unsupported')
"
class="side-descriptor"
>
<InfoIcon aria-hidden="true" />
Server {{ projectTypeDisplay }}
</div>
</div>
<p class="description"> <p class="description">
{{ project.description }} {{ project.description }}
</p> </p>
@ -93,7 +177,13 @@
:categories="project.categories" :categories="project.categories"
:type="project.actualProjectType" :type="project.actualProjectType"
class="categories" class="categories"
>
<EnvironmentIndicator
:client-side="project.client_side"
:server-side="project.server_side"
:type="project.project_type"
/> />
</Categories>
<hr class="card-divider" /> <hr class="card-divider" />
<div class="primary-stat"> <div class="primary-stat">
<DownloadIcon class="primary-stat__icon" aria-hidden="true" /> <DownloadIcon class="primary-stat__icon" aria-hidden="true" />
@ -116,7 +206,9 @@
<div class="dates"> <div class="dates">
<div <div
v-tooltip=" v-tooltip="
$dayjs(project.published).format('MMMM D, YYYY [at] h:mm:ss A') $dayjs(project.published).format(
'MMMM D, YYYY [at] h:mm:ss A'
)
" "
class="date" class="date"
> >
@ -134,7 +226,9 @@
> >
<UpdateIcon aria-hidden="true" /> <UpdateIcon aria-hidden="true" />
<span class="label">Updated</span> <span class="label">Updated</span>
<span class="value">{{ $dayjs(project.updated).fromNow() }}</span> <span class="value">{{
$dayjs(project.updated).fromNow()
}}</span>
</div> </div>
</div> </div>
<hr class="card-divider" /> <hr class="card-divider" />
@ -165,29 +259,24 @@
</button> </button>
</template> </template>
<template v-else> <template v-else>
<a <a class="iconified-button" :href="authUrl">
class="iconified-button"
:href="authUrl"
rel="noopener noreferrer nofollow"
>
<ReportIcon aria-hidden="true" /> <ReportIcon aria-hidden="true" />
Report Report
</a> </a>
<a <a class="iconified-button" :href="authUrl">
class="iconified-button" <HeartIcon fill="currentColor" aria-hidden="true" />
:href="authUrl"
rel="noopener noreferrer nofollow"
>
<HeartIcon aria-hidden="true" />
Follow Follow
</a> </a>
</template> </template>
</div> </div>
</div> </div>
</div>
<div <div
v-if=" v-if="
currentMember && currentMember &&
(project.status !== 'approved' || ((project.status !== 'approved' &&
project.status !== 'draft' &&
project.status !== 'processing') ||
(project.moderator_message && (project.moderator_message &&
(project.moderator_message.message || (project.moderator_message.message ||
project.moderator_message.body))) project.moderator_message.body)))
@ -207,19 +296,6 @@
which is below. Do not resubmit until you've addressed the message which is below. Do not resubmit until you've addressed the message
from the moderators! from the moderators!
</p> </p>
<p v-if="project.status === 'processing'">
Your project is currently not viewable by people who are not part
of your team. Please wait for our moderators to manually review
your project to see if it abides by our
<nuxt-link class="text-link" to="/legal/rules"
>content rules!
</nuxt-link>
</p>
<p v-if="project.status === 'draft'">
Your project is currently not viewable by people who are not part
of your team. If you would like to publish your project, click the
button below to send your project in for review.
</p>
<div v-if="project.moderator_message"> <div v-if="project.moderator_message">
<hr class="card-divider" /> <hr class="card-divider" />
<div v-if="project.moderator_message.body"> <div v-if="project.moderator_message.body">
@ -260,14 +336,6 @@
<CheckIcon /> <CheckIcon />
Resubmit for review Resubmit for review
</button> </button>
<button
v-if="project.status === 'draft'"
class="iconified-button brand-button"
@click="submitForReview"
>
<CheckIcon />
Submit for review
</button>
<button <button
v-if="project.status === 'approved'" v-if="project.status === 'approved'"
class="iconified-button" class="iconified-button"
@ -550,6 +618,16 @@
</div> </div>
</div> </div>
<section class="normal-page__content"> <section class="normal-page__content">
<ProjectPublishingChecklist
:project="project"
:versions="versions"
:current-member="currentMember"
:is-settings="isSettings"
:route-name="routeName"
:set-processing="setProcessing"
:collapsed="collapsedChecklist"
:toggle-collapsed="toggleChecklistCollapse"
/>
<div <div
v-if="project.status === 'unlisted'" v-if="project.status === 'unlisted'"
class="card warning" class="card warning"
@ -618,6 +696,7 @@
type="banner" type="banner"
small-screen="square" small-screen="square"
/> />
<div class="navigation-card">
<NavRow <NavRow
:links="[ :links="[
{ {
@ -647,16 +726,17 @@
}/versions`, }/versions`,
shown: project.versions.length > 0 || !!currentMember, shown: project.versions.length > 0 || !!currentMember,
}, },
{
label: 'Settings',
href: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings`,
shown: !!currentMember,
},
]" ]"
class="card"
/> />
<div v-if="$auth.user && currentMember" class="input-group">
<nuxt-link
:to="`/${project.project_type}/${project.slug}/settings`"
class="iconified-button"
>
<SettingsIcon /> Settings
</nuxt-link>
</div>
</div>
<NuxtChild <NuxtChild
:project.sync="project" :project.sync="project"
:versions.sync="versions" :versions.sync="versions"
@ -680,7 +760,6 @@ import UpdateIcon from '~/assets/images/utils/updated.svg?inline'
import CodeIcon from '~/assets/images/sidebar/mod.svg?inline' import CodeIcon from '~/assets/images/sidebar/mod.svg?inline'
import ReportIcon from '~/assets/images/utils/report.svg?inline' import ReportIcon from '~/assets/images/utils/report.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg?inline' import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import InfoIcon from '~/assets/images/utils/info.svg?inline'
import IssuesIcon from '~/assets/images/utils/issues.svg?inline' import IssuesIcon from '~/assets/images/utils/issues.svg?inline'
import WikiIcon from '~/assets/images/utils/wiki.svg?inline' import WikiIcon from '~/assets/images/utils/wiki.svg?inline'
import DiscordIcon from '~/assets/images/external/discord.svg?inline' import DiscordIcon from '~/assets/images/external/discord.svg?inline'
@ -691,14 +770,27 @@ import PayPalIcon from '~/assets/images/external/paypal.svg?inline'
import OpenCollectiveIcon from '~/assets/images/external/opencollective.svg?inline' import OpenCollectiveIcon from '~/assets/images/external/opencollective.svg?inline'
import UnknownIcon from '~/assets/images/utils/unknown-donation.svg?inline' import UnknownIcon from '~/assets/images/utils/unknown-donation.svg?inline'
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline' import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline'
import EyeIcon from '~/assets/images/utils/eye.svg?inline'
import Advertisement from '~/components/ads/Advertisement' import Advertisement from '~/components/ads/Advertisement'
import Badge from '~/components/ui/Badge' import Badge from '~/components/ui/Badge'
import Categories from '~/components/ui/search/Categories' import Categories from '~/components/ui/search/Categories'
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
import ModalReport from '~/components/ui/ModalReport' import ModalReport from '~/components/ui/ModalReport'
import NavRow from '~/components/ui/NavRow' import NavRow from '~/components/ui/NavRow'
import CopyCode from '~/components/ui/CopyCode' import CopyCode from '~/components/ui/CopyCode'
import Avatar from '~/components/ui/Avatar' import Avatar from '~/components/ui/Avatar'
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import ProjectPublishingChecklist from '~/components/ui/ProjectPublishingChecklist'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import UsersIcon from '~/assets/images/utils/users.svg?inline'
import CategoriesIcon from '~/assets/images/utils/tags.svg?inline'
import DescriptionIcon from '~/assets/images/utils/align-left.svg?inline'
import LinksIcon from '~/assets/images/utils/link.svg?inline'
import LicenseIcon from '~/assets/images/utils/copyright.svg?inline'
import GalleryIcon from '~/assets/images/utils/image.svg?inline'
import VersionIcon from '~/assets/images/utils/version.svg?inline'
export default { export default {
components: { components: {
@ -709,6 +801,8 @@ export default {
Advertisement, Advertisement,
Modal, Modal,
ModalReport, ModalReport,
ProjectPublishingChecklist,
EnvironmentIndicator,
IssuesIcon, IssuesIcon,
DownloadIcon, DownloadIcon,
CalendarIcon, CalendarIcon,
@ -718,7 +812,6 @@ export default {
CodeIcon, CodeIcon,
ReportIcon, ReportIcon,
HeartIcon, HeartIcon,
InfoIcon,
WikiIcon, WikiIcon,
DiscordIcon, DiscordIcon,
BuyMeACoffeeLogo, BuyMeACoffeeLogo,
@ -729,6 +822,17 @@ export default {
PatreonIcon, PatreonIcon,
KoFiIcon, KoFiIcon,
ChevronRightIcon, ChevronRightIcon,
NavStack,
NavStackItem,
SettingsIcon,
EyeIcon,
GalleryIcon,
VersionIcon,
UsersIcon,
CategoriesIcon,
DescriptionIcon,
LinksIcon,
LicenseIcon,
}, },
async asyncData(data) { async asyncData(data) {
try { try {
@ -868,9 +972,14 @@ export default {
return { return {
showKnownErrors: false, showKnownErrors: false,
licenseText: '', licenseText: '',
isSettings: false,
routeName: '',
from: '',
collapsedChecklist: false,
} }
}, },
fetch() { fetch() {
this.reset()
this.versions = this.$computeVersions(this.versions) this.versions = this.$computeVersions(this.versions)
this.featuredVersions = this.$computeVersions(this.featuredVersions) this.featuredVersions = this.$computeVersions(this.featuredVersions)
}, },
@ -948,8 +1057,26 @@ export default {
return id return id
} }
}, },
featuredGalleryImage() {
return this.project.gallery.find((img) => img.featured)
},
},
watch: {
'$route.path': {
async handler() {
await this.reset()
},
},
}, },
methods: { methods: {
reset() {
// First time going to settings, this will run, but not subsequent times.
if (!this.isSettings) {
this.from = this.$nuxt.context.from ? this.$nuxt.context.from.name : ''
}
this.routeName = this.$route.name
this.isSettings = this.routeName.startsWith('type-id-settings')
},
async resetProject() { async resetProject() {
const project = ( const project = (
await this.$axios.get( await this.$axios.get(
@ -1018,12 +1145,20 @@ export default {
async submitForReview() { async submitForReview() {
if ( if (
this.project.body === '' || this.project.body === '' ||
this.project.body.startsWith('# Placeholder description') ||
this.versions.length < 1 || this.versions.length < 1 ||
this.project.client_side === 'unknown' || this.project.client_side === 'unknown' ||
this.project.server_side === 'unknown' this.project.server_side === 'unknown'
) { ) {
this.showKnownErrors = true this.showKnownErrors = true
} else { } else {
await this.setProcessing()
}
},
toggleChecklistCollapse() {
this.collapsedChecklist = !this.collapsedChecklist
},
async setProcessing() {
this.$nuxt.$loading.start() this.$nuxt.$loading.start()
try { try {
@ -1046,7 +1181,6 @@ export default {
} }
this.$nuxt.$loading.finish() this.$nuxt.$loading.finish()
}
}, },
async getLicenseData() { async getLicenseData() {
try { try {
@ -1060,6 +1194,104 @@ export default {
this.$refs.modal_license.show() this.$refs.modal_license.show()
}, },
async patchProject(data, quiet = false) {
let result = false
this.$nuxt.$loading.start()
try {
await this.$axios.patch(
`project/${this.project.id}`,
data,
this.$defaultHeaders()
)
if (this.iconChanged) {
await this.$axios.patch(
`project/${this.project.id}/icon?ext=${
this.icon.type.split('/')[this.icon.type.split('/').length - 1]
}`,
this.icon,
this.$defaultHeaders()
)
}
for (const key in data) {
this.project[key] = data[key]
}
if (data.license_id) {
this.project.license.id = data.license_id
}
if (data.license_url) {
this.project.license.url = data.license_url
}
this.$emit('update:project', this.project)
result = true
if (!quiet) {
this.$notify({
group: 'main',
title: 'Project updated',
text: 'Your project has been updated.',
type: 'success',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
this.$nuxt.$loading.finish()
return result
},
async patchIcon(icon) {
let result = false
this.$nuxt.$loading.start()
try {
await this.$axios.patch(
`project/${this.project.id}/icon?ext=${
icon.type.split('/')[icon.type.split('/').length - 1]
}`,
icon,
this.$defaultHeaders()
)
await this.updateIcon()
result = true
this.$notify({
group: 'main',
title: 'Project icon updated',
text: "Your project's icon has been updated.",
type: 'success',
})
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
this.$nuxt.$loading.finish()
return result
},
async updateIcon() {
const response = await this.$axios.get(
`project/${this.project.id}`,
this.$defaultHeaders()
)
this.project.icon_url = response.data.icon_url
},
}, },
} }
</script> </script>
@ -1068,23 +1300,13 @@ export default {
grid-area: header; grid-area: header;
.title { .title {
overflow-wrap: break-word; overflow-wrap: break-word;
margin: 0.25rem 0; margin: var(--spacing-card-xs) 0;
color: var(--color-text-dark); color: var(--color-text-dark);
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
} }
.side-descriptor { .status-badge {
display: flex; margin-top: var(--spacing-card-sm);
align-items: center;
color: var(--color-text-dark);
font-weight: bold;
font-size: var(--font-size-sm);
margin-bottom: 0.5rem;
svg {
height: 1.25rem;
margin-right: 0.125rem;
}
} }
.description { .description {
@ -1125,6 +1347,38 @@ export default {
} }
} }
.project__header {
overflow: hidden;
.project__gallery {
display: none;
}
&.has-featured-image {
.project__gallery {
display: inline-block;
width: 100%;
height: 10rem;
background-color: var(--color-button-bg-active);
img {
width: 100%;
height: 10rem;
object-fit: cover;
}
}
.project__icon {
margin-top: calc(-3rem - var(--spacing-card-lg) - 4px);
margin-left: -4px;
z-index: 1;
border: 4px solid var(--color-raised-bg);
border-bottom: none;
}
}
.project__header__content {
margin: 0;
background: none;
border-radius: unset;
}
}
.project-info { .project-info {
height: auto; height: auto;
overflow: hidden; overflow: hidden;
@ -1305,4 +1559,23 @@ export default {
.modal-license { .modal-license {
padding: var(--spacing-card-bg); padding: var(--spacing-card-bg);
} }
.settings-header {
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
align-items: center;
margin-bottom: var(--spacing-card-bg);
.settings-header__icon {
flex-shrink: 0;
}
.settings-header__text {
h1 {
font-size: var(--font-size-md);
margin-top: 0;
margin-bottom: var(--spacing-card-sm);
}
}
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,144 @@
<template>
<div>
<section class="universal-card">
<label for="project-description">
<span class="label__title size-card-header">Description</span>
<span class="label__description">
You can type an extended description of your mod here. This editor
supports
<a
class="text-link"
href="https://guides.github.com/features/mastering-markdown/"
target="_blank"
rel="noopener noreferrer"
>Markdown</a
>. HTML can also be used inside your description, not including
styles, scripts, and iframes (though YouTube iframes are allowed).
<span class="label__subdescription">
The description must clearly and honestly describe the purpose and
function of the project. See section 2.1 of the
<nuxt-link to="/legal/rules" class="text-link" target="_blank"
>Content Rules</nuxt-link
>
for the full requirements.
</span>
</span>
</label>
<Chips v-model="bodyViewMode" :items="['source', 'preview']" />
<div v-if="bodyViewMode === 'source'" class="resizable-textarea-wrapper">
<textarea
id="project-description"
v-model="description"
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
/>
</div>
<div
v-else-if="bodyViewMode === 'preview'"
v-highlightjs
class="markdown-body"
v-html="
description ? $xss($md.render(description)) : 'No body specified.'
"
></div>
<div class="input-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Chips from '~/components/ui/Chips'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
export default {
components: {
Chips,
SaveIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
allMembers: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
return {
description: '',
bodyViewMode: 'source',
}
},
fetch() {
this.description = this.project.body
},
computed: {
patchData() {
const data = {}
if (this.description !== this.project.body) {
data.body = this.description
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
created() {
this.EDIT_BODY = 1 << 3
},
methods: {
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
}
</script>
<style lang="scss" scoped>
.resizable-textarea-wrapper textarea {
min-height: 40rem;
}
.markdown-body {
margin-bottom: var(--spacing-card-md);
}
</style>

View File

@ -0,0 +1,419 @@
<template>
<div>
<ModalConfirm
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
:has-to-type="true"
:confirmation-text="project.title"
proceed-label="Delete"
@proceed="deleteProject"
/>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
<label for="project-name">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="
deletedIcon ? null : previewImage ? previewImage : project.icon_url
"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon />
Remove icon
</button>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="project-name"
v-model="name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
/>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/mod/</div>
<input
id="project-slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
></textarea>
</div>
<template
v-if="
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has
functionality on the client side. Just because a mod works in
Singleplayer doesn't mean it has actual client-side functionality.
</span>
</label>
<Multiselect
id="project-env-client"
v-model="clientSide"
placeholder="Select one"
:options="sideTypes"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has
functionality on the <strong>logical</strong> server. Remember
that Singleplayer contains an integrated server.
</span>
</label>
<Multiselect
id="project-env-server"
v-model="serverSide"
placeholder="Select one"
:options="sideTypes"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
</template>
<div class="adjacent-input">
<label for="project-visibility">
<span class="label__title">Visibility</span>
<span class="label__description">
Set the visibility of your project. Listed and archived projects are
visible in search. Unlisted projects are published, but not visible
in search or on user profiles. Private projects are only accessible
by members of the project.
</span>
</label>
<Multiselect
id="project-visibility"
v-model="visibility"
placeholder="Select one"
:options="statusOptions"
:custom-label="(value) => $formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete project</span>
</h3>
</div>
<p>
Removes your project from Modrinth's servers and search. Clicking on
this will delete your project, so be extra careful!
</p>
<button
type="button"
class="iconified-button danger-button"
:disabled="!hasDeletePermission"
@click="$refs.modal_confirm.show()"
>
<TrashIcon />
Delete project
</button>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import Avatar from '~/components/ui/Avatar'
import ModalConfirm from '~/components/ui/ModalConfirm'
import FileInput from '~/components/ui/FileInput'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
export default {
components: {
Avatar,
ModalConfirm,
FileInput,
Multiselect,
UploadIcon,
SaveIcon,
TrashIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
patchIcon: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch icon function not found',
type: 'error',
})
}
},
},
updateIcon: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Update icon function not found',
type: 'error',
})
}
},
},
},
data() {
return {
name: '',
slug: '',
summary: '',
icon: null,
previewImage: null,
clientSide: '',
serverSide: '',
deletedIcon: false,
visibility: '',
}
},
fetch() {
this.name = this.project.title
this.slug = this.project.slug
this.summary = this.project.description
this.clientSide = this.project.client_side
this.serverSide = this.project.server_side
this.visibility = this.statusOptions.includes(this.project.status)
? this.project.status
: this.project.requested_status
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
},
hasDeletePermission() {
const DELETE_PROJECT = 1 << 7
return (
(this.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
)
},
sideTypes() {
return ['required', 'optional', 'unsupported']
},
statusOptions() {
return ['approved', 'archived', 'unlisted', 'private']
},
patchData() {
const data = {}
if (this.name !== this.project.title) {
data.title = this.name
}
if (this.slug !== this.project.slug) {
data.slug = this.slug
}
if (this.summary !== this.project.description) {
data.description = this.summary
}
if (this.clientSide !== this.project.client_side) {
data.client_side = this.clientSide
}
if (this.serverSide !== this.project.server_side) {
data.server_side = this.serverSide
}
if (this.visibility !== this.project.requested_status) {
if (!this.statusOptions.includes(this.project.status)) {
data.requested_status = this.visibility
} else {
data.status = this.visibility
}
}
return data
},
hasChanges() {
return (
Object.keys(this.patchData).length > 0 || this.deletedIcon || this.icon
)
},
},
methods: {
async saveChanges() {
if (this.hasChanges) {
await this.patchProject(this.patchData)
}
if (this.deletedIcon) {
await this.deleteIcon()
this.deletedIcon = false
} else if (this.icon) {
await this.patchIcon(this.icon)
this.icon = null
}
},
showPreviewImage(files) {
const reader = new FileReader()
this.icon = files[0]
this.deletedIcon = false
reader.readAsDataURL(this.icon)
reader.onload = (event) => {
this.previewImage = event.target.result
}
},
async deleteProject() {
await this.$axios.delete(
`project/${this.project.id}`,
this.$defaultHeaders()
)
await this.$store.dispatch('user/fetchProjects')
await this.$router.push(`/dashboard/projects`)
this.$notify({
group: 'main',
title: 'Project deleted',
text: 'Your project has been deleted.',
type: 'success',
})
},
markIconForDeletion() {
this.deletedIcon = true
this.icon = null
this.previewImage = null
},
async deleteIcon() {
await this.$axios.delete(
`project/${this.project.id}/icon`,
this.$defaultHeaders()
)
await this.updateIcon()
this.$notify({
group: 'main',
title: 'Project icon removed',
text: "Your project's icon has been removed.",
type: 'success',
})
},
},
}
</script>
<style lang="scss" scoped>
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
</style>

View File

@ -0,0 +1,286 @@
<template>
<div>
<section class="universal-card">
<div class="adjacent-input">
<label for="license-multiselect">
<span class="label__title size-card-header">License</span>
<span class="label__description">
It is very important to choose a proper license for your
{{ $formatProjectType(project.project_type).toLowerCase() }}. You
may choose one from our list or provide a custom license. You may
also provide a custom URL to your chosen license; otherwise, the
license text will be displayed.
<span
v-if="license && license.friendly === 'Custom'"
class="label__subdescription"
>
Enter a valid
<a
href="https://spdx.org/licenses/"
target="_blank"
rel="noopener noreferrer"
class="text-link"
>
SPDX license identifier</a
>
in the marked area. If your license does not have a SPDX
identifier (for example, if you created the license yourself or if
the license is Minecraft-specific), simply check the box and enter
the name of the license instead.
</span>
<span class="label__subdescription">
Confused? See our
<a
href="https://blog.modrinth.com/licensing-guide/"
target="_blank"
rel="noopener noreferrer"
class="text-link"
>
licensing guide</a
>
for more information.
</span>
</span>
</label>
<div class="input-stack">
<Multiselect
id="license-multiselect"
v-model="license"
placeholder="Select license..."
track-by="short"
label="friendly"
:options="defaultLicenses"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:class="{
'known-error': license.short === '' && showKnownErrors,
}"
:disabled="!hasPermission"
/>
<Checkbox
v-if="license.requiresOnlyOrLater"
v-model="allowOrLater"
:disabled="!hasPermission"
>
Allow later editions of this license
</Checkbox>
<Checkbox
v-if="license.friendly === 'Custom'"
v-model="nonSpdxLicense"
:disabled="!hasPermission"
>
License does not have a SPDX identifier
</Checkbox>
<input
v-if="license.friendly === 'Custom'"
v-model="license.short"
type="text"
maxlength="2048"
:placeholder="nonSpdxLicense ? 'License name' : 'SPDX identifier'"
:class="{
'known-error': license.short === '' && showKnownErrors,
}"
:disabled="!hasPermission"
/>
<input
v-model="licenseUrl"
type="url"
maxlength="2048"
placeholder="License URL (optional)"
:disabled="!hasPermission"
/>
</div>
</div>
<div class="input-stack">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import Checkbox from '~/components/ui/Checkbox'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
export default {
components: {
Multiselect,
Checkbox,
SaveIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
return {
licenseUrl: '',
license: { friendly: '', short: '', requiresOnlyOrLater: false },
allowOrLater: false,
nonSpdxLicense: false,
showKnownErrors: false,
}
},
fetch() {
this.licenseUrl = this.project.license.url
const licenseId = this.project.license.id
const trimmedLicenseId = licenseId
.replaceAll('-only', '')
.replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '')
this.license = this.defaultLicenses.find(
(x) => x.short === trimmedLicenseId
) ?? {
friendly: 'Custom',
short: licenseId.replaceAll('LicenseRef-', ''),
}
this.allowOrLater = licenseId.includes('-or-later')
this.nonSpdxLicense = licenseId.includes('LicenseRef-')
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
},
licenseId() {
let id = ''
if (this.nonSpdxLicense || this.license.short === 'All-Rights-Reserved')
id += 'LicenseRef-'
id += this.license.short
if (this.license.requiresOnlyOrLater)
id += this.allowOrLater ? 'or-later' : '-only'
if (this.nonSpdxLicense) id.replaceAll(' ', '-')
return id
},
defaultLicenses() {
return [
{ friendly: 'Custom', short: '' },
{
friendly: 'All Rights Reserved/No License',
short: 'All-Rights-Reserved',
},
{ friendly: 'Apache License 2.0', short: 'Apache-2.0' },
{
friendly: 'BSD 2-Clause "Simplified" License',
short: 'BSD-2-Clause',
},
{
friendly: 'BSD 3-Clause "New" or "Revised" License',
short: 'BSD-3-Clause',
},
{
friendly: 'CC Zero (Public Domain equivalent)',
short: 'CC0-1.0',
},
{ friendly: 'CC-BY 4.0', short: 'CC-BY-4.0' },
{
friendly: 'CC-BY-SA 4.0',
short: 'CC-BY-SA-4.0',
},
{
friendly: 'CC-BY-NC 4.0',
short: 'CC-BY-NC-4.0',
},
{
friendly: 'CC-BY-NC-SA 4.0',
short: 'CC-BY-NC-SA-4.0',
},
{
friendly: 'CC-BY-ND 4.0',
short: 'CC-BY-ND-4.0',
},
{
friendly: 'CC-BY-NC-ND 4.0',
short: 'CC-BY-NC-ND-4.0',
},
{
friendly: 'GNU Affero General Public License v3',
short: 'AGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v2.1',
short: 'LGPL-2.1',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v3',
short: 'LGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v2',
short: 'GPL-2.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v3',
short: 'GPL-3.0',
requiresOnlyOrLater: true,
},
{ friendly: 'ISC License', short: 'ISC' },
{ friendly: 'MIT License', short: 'MIT' },
{ friendly: 'Mozilla Public License 2.0', short: 'MPL-2.0' },
{ friendly: 'zlib License', short: 'Zlib' },
]
},
patchData() {
const data = {}
if (this.licenseId !== this.project.license.id) {
data.license_id = this.licenseId
data.license_url = this.licenseUrl ? this.licenseUrl : null
} else if (this.licenseUrl !== this.project.license.url) {
data.license_url = this.licenseUrl ? this.licenseUrl : null
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
methods: {
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
}
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,286 @@
<template>
<div>
<section class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label
id="project-issue-tracker"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker</span>
<span class="label__description">
A place for users to report bugs, issues, and concerns about your
project.
</span>
</label>
<input
id="project-issue-tracker"
v-model="issuesUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-source-code"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code</span>
<span class="label__description">
A page/repository containing the source code for your project
</span>
</label>
<input
id="project-source-code"
v-model="sourceUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-wiki-page"
title="A page containing information, documentation, and help for the project."
>
<span class="label__title">Wiki page</span>
<span class="label__description">
A page containing information, documentation, and help for the
project.
</span>
</label>
<input
id="project-wiki-page"
v-model="wikiUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-discord-invite"
title="An invitation link to your Discord server."
>
<span class="label__title">Discord invite</span>
<span class="label__description">
An invitation link to your Discord server.
</span>
</label>
<input
id="project-discord-invite"
v-model="discordUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<span class="label">
<span class="label__title">Donation links</span>
<span class="label__description">
Add donation links for users to support you directly.
</span>
</span>
<div
v-for="(donationLink, index) in donationLinks"
:key="`donation-link-${index}`"
class="input-group donation-link-group"
>
<Multiselect
v-model="donationLink.platform"
placeholder="Select platform"
:options="$tag.donationPlatforms.map((x) => x.name)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:disabled="!hasPermission"
@input="updateDonationLinks"
/>
<input
v-model="donationLink.url"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
@input="updateDonationLinks"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
export default {
components: {
Multiselect,
SaveIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
return {
issuesUrl: '',
sourceUrl: '',
wikiUrl: '',
discordUrl: '',
donationLinks: [],
}
},
fetch() {
this.issuesUrl = this.project.issues_url
this.sourceUrl = this.project.source_url
this.wikiUrl = this.project.wiki_url
this.discordUrl = this.project.discord_url
this.resetDonationLinks()
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
},
patchData() {
const data = {}
if (this.checkDifference(this.issuesUrl, this.project.issues_url)) {
data.issues_url = this.issuesUrl
}
if (this.checkDifference(this.sourceUrl, this.project.source_url)) {
data.source_url = this.sourceUrl
}
if (this.checkDifference(this.wikiUrl, this.project.wiki_url)) {
data.wiki_url = this.wikiUrl
}
if (this.checkDifference(this.discordUrl, this.project.discord_url)) {
data.discord_url = this.discordUrl
}
const donationLinks = this.donationLinks.filter(
(link) => link.url && link.platform
)
donationLinks.forEach((link) => {
link.id = this.$tag.donationPlatforms.find(
(platform) => platform.name === link.platform
).short
})
if (
donationLinks !== this.project.donation_urls &&
!(
this.project.donation_urls &&
this.project.donation_urls.length === 0 &&
donationLinks.length === 0
)
) {
data.donation_urls = donationLinks
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
methods: {
async saveChanges() {
if (this.patchData && (await this.patchProject(this.patchData))) {
this.resetDonationLinks()
}
},
updateDonationLinks() {
this.donationLinks.forEach((link) => {
if (link.url) {
const url = link.url.toLowerCase()
if (url.includes('patreon.com')) {
link.platform = 'Patreon'
} else if (url.includes('ko-fi.com')) {
link.platform = 'Ko-fi'
} else if (url.includes('paypal.com')) {
link.platform = 'Paypal'
} else if (url.includes('buymeacoffee.com')) {
link.platform = 'Buy Me a Coffee'
} else if (url.includes('github.com/sponsors')) {
link.platform = 'GitHub Sponsors'
}
}
})
if (!this.donationLinks.find((link) => !(link.url && link.platform))) {
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
}
},
resetDonationLinks() {
this.donationLinks = JSON.parse(
JSON.stringify(this.project.donation_urls)
)
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
},
checkDifference(a, b) {
if (!a && !b) {
return false
}
return a !== b
},
},
}
</script>
<style lang="scss" scoped>
.donation-link-group {
input {
flex-grow: 2;
max-width: 26rem;
}
}
</style>

View File

@ -1,54 +1,16 @@
<template> <template>
<div> <div>
<ModalConfirm
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
:has-to-type="true"
:confirmation-text="project.title"
proceed-label="Delete"
@proceed="deleteProject"
/>
<div class="universal-card"> <div class="universal-card">
<h2>General settings</h2> <div class="label">
<div class="adjacent-input"> <h3>
<label> <span class="label__title size-card-header">Manage members</span>
<span class="label__title">Edit project information</span> </h3>
<span class="label__description">
Edit your project's name, description, categories, and more.
</span>
</label>
<nuxt-link class="iconified-button" to="edit"
><EditIcon />Edit</nuxt-link
>
</div> </div>
<div class="adjacent-input">
<span class="label">
<span class="label__title">Delete project</span>
<span class="label__description">
Removes your project from Modrinth's servers and search. Clicking on
this will delete your project, so be extra careful!
</span>
</span>
<button
class="iconified-button danger-button"
:disabled="
(currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT
"
@click="$refs.modal_confirm.show()"
>
<TrashIcon />Delete project
</button>
</div>
</div>
<div class="universal-card">
<h2>Manage members</h2>
<div class="adjacent-input">
<span class="label"> <span class="label">
<span class="label__title">Invite a member</span> <span class="label__title">Invite a member</span>
<span class="label__description"> <span class="label__description">
Enter the Modrinth username of the person you'd like to invite to be Enter the Modrinth username of the person you'd like to invite to be a
a member of this project. member of this project.
</span> </span>
</span> </span>
<div <div
@ -62,16 +24,12 @@
placeholder="Username" placeholder="Username"
/> />
<label for="username" class="hidden">Username</label> <label for="username" class="hidden">Username</label>
<button <button class="iconified-button brand-button" @click="inviteTeamMember">
class="iconified-button brand-button" <UserPlusIcon />
@click="inviteTeamMember"
>
<PlusIcon />
Invite Invite
</button> </button>
</div> </div>
</div> </div>
</div>
<div <div
v-for="(member, index) in allTeamMembers" v-for="(member, index) in allTeamMembers"
:key="member.user.id" :key="member.user.id"
@ -94,10 +52,10 @@
</div> </div>
</div> </div>
<div class="side-buttons"> <div class="side-buttons">
<Badge v-if="member.accepted" type="accepted" color="green" /> <Badge v-if="member.accepted" type="accepted" />
<Badge v-else type="pending" color="orange" /> <Badge v-else type="pending" />
<button <button
class="dropdown-icon" class="square-button dropdown-icon"
@click=" @click="
openTeamMembers.indexOf(member.user.id) === -1 openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id) ? openTeamMembers.push(member.user.id)
@ -250,16 +208,26 @@
/> />
</div> </div>
</template> </template>
<div class="button-group push-right"> <div class="input-group">
<button
class="iconified-button brand-button"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
@click="updateTeamMember(index)"
>
<SaveIcon />
Save changes
</button>
<button <button
v-if="member.oldRole !== 'Owner'" v-if="member.oldRole !== 'Owner'"
class="iconified-button" class="iconified-button danger-button"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
" "
@click="removeTeamMember(index)" @click="removeTeamMember(index)"
> >
<TrashIcon /> <UserRemoveIcon />
Remove member Remove member
</button> </button>
<button <button
@ -271,19 +239,9 @@
class="iconified-button" class="iconified-button"
@click="transferOwnership(index)" @click="transferOwnership(index)"
> >
<UserIcon /> <TransferIcon />
Transfer ownership Transfer ownership
</button> </button>
<button
class="iconified-button brand-button"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
@click="updateTeamMember(index)"
>
<CheckIcon />
Save changes
</button>
</div> </div>
</div> </div>
</div> </div>
@ -291,30 +249,26 @@
</template> </template>
<script> <script>
import ModalConfirm from '~/components/ui/ModalConfirm'
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
import Badge from '~/components/ui/Badge' import Badge from '~/components/ui/Badge'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline' import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline' import TransferIcon from '~/assets/images/utils/transfer.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline' import UserPlusIcon from '~/assets/images/utils/user-plus.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline' import UserRemoveIcon from '~/assets/images/utils/user-x.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import Avatar from '~/components/ui/Avatar' import Avatar from '~/components/ui/Avatar'
export default { export default {
components: { components: {
Avatar, Avatar,
DropdownIcon, DropdownIcon,
ModalConfirm,
Checkbox, Checkbox,
Badge, Badge,
PlusIcon, SaveIcon,
CheckIcon, TransferIcon,
EditIcon, UserPlusIcon,
TrashIcon, UserRemoveIcon,
UserIcon,
}, },
props: { props: {
project: { project: {
@ -430,6 +384,12 @@ export default {
this.$defaultHeaders() this.$defaultHeaders()
) )
await this.updateMembers() await this.updateMembers()
this.$notify({
group: 'main',
title: 'Member(s) updated',
text: `Your project's member(s) has been updated.`,
type: 'success',
})
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
@ -464,20 +424,6 @@ export default {
this.$nuxt.$loading.finish() this.$nuxt.$loading.finish()
}, },
async deleteProject() {
await this.$axios.delete(
`project/${this.project.id}`,
this.$defaultHeaders()
)
await this.$store.dispatch('user/fetchProjects')
await this.$router.push(`/user/${this.$auth.user.username}`)
this.$notify({
group: 'main',
title: 'Action Success',
text: 'Your project has been successfully deleted.',
type: 'success',
})
},
async updateMembers() { async updateMembers() {
this.allTeamMembers = ( this.allTeamMembers = (
await this.$axios.get( await this.$axios.get(
@ -518,11 +464,10 @@ export default {
align-items: center; align-items: center;
.dropdown-icon { .dropdown-icon {
margin-left: 1rem; margin-left: 1rem;
cursor: pointer;
color: var(--color-text-dark); svg {
background-color: unset;
transition: 150ms ease transform; transition: 150ms ease transform;
padding: unset; }
} }
} }
} }
@ -546,7 +491,7 @@ export default {
&.open { &.open {
.member-header { .member-header {
.dropdown-icon { .dropdown-icon svg {
transform: rotate(180deg); transform: rotate(180deg);
} }
} }

View File

@ -0,0 +1,319 @@
<template>
<div>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Tags</span>
</h3>
</div>
<p>
Accurate tagging is important to help people find your
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure
to select all tags that apply.
</p>
<template v-for="header in Object.keys(categoryLists)">
<div :key="`categories-${header}`" class="label">
<h4>
<span class="label__title">{{
$formatCategoryHeader(header)
}}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
Select all categories that reflect the themes or function of your
{{ $formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'features'">
Select all of the features that your
{{ $formatProjectType(project.project_type).toLowerCase() }} makes
use of.
</template>
<template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your
{{ $formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'performance impact'">
Select the realistic performance impact of your
{{ $formatProjectType(project.project_type).toLowerCase() }}.
Select multiple if the
{{ $formatProjectType(project.project_type).toLowerCase() }} is
configurable to different levels of performance impact.
</template>
</span>
</div>
<div :key="`categories-${header}-list`" class="category-list input-div">
<Checkbox
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:value="selectedTags.includes(category)"
:description="$formatCategory(category.name)"
class="category-selector"
@input="toggleCategory(category)"
>
<div class="category-selector__label">
<div
v-if="header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
></div>
<span aria-hidden="true">
{{ $formatCategory(category.name) }}</span
>
</div>
</Checkbox>
</div>
</template>
<div class="label">
<h4>
<span class="label__title"><StarIcon /> Featured tags</span>
</h4>
<span class="label__description">
You can feature up to 3 of your most relevant tags. Other tags may be
promoted to featured if you do not select all 3.
</span>
</div>
<p v-if="selectedTags.length < 1">
Select at least one category in order to feature a category.
</p>
<div class="category-list input-div">
<Checkbox
v-for="category in selectedTags"
:key="`featured-category-${category.name}`"
class="category-selector"
:value="featuredTags.includes(category)"
:description="$formatCategory(category.name)"
:disabled="
featuredTags.length >= 3 && !featuredTags.includes(category)
"
@input="toggleFeaturedCategory(category)"
>
<div class="category-selector__label">
<div
v-if="category.header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
></div>
<span aria-hidden="true">
{{ $formatCategory(category.name) }}</span
>
</div>
</Checkbox>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Checkbox from '~/components/ui/Checkbox'
import StarIcon from '~/assets/images/utils/star.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
export default {
components: {
Checkbox,
SaveIcon,
StarIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
allMembers: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
return {
selectedTags: [],
featuredTags: [],
}
},
fetch() {
this.selectedTags = this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name))
)
this.featuredTags = this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name)
)
},
computed: {
categoryLists() {
const lists = {}
this.$sortedCategories.forEach((x) => {
if (x.project_type === this.project.actualProjectType) {
const header = x.header
if (!lists[header]) {
lists[header] = []
}
lists[header].push(x)
}
})
return lists
},
patchData() {
const data = {}
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = this.featuredTags.slice()
if (
newFeaturedTags.length < 1 &&
this.selectedTags.length > newFeaturedTags.length
) {
const nonFeaturedCategories = this.selectedTags.filter(
(x) => !newFeaturedTags.includes(x)
)
nonFeaturedCategories
.slice(
0,
Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length)
)
.forEach((x) => newFeaturedTags.push(x))
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name)
const additionalCategories = this.selectedTags
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name)
if (
categories.length !== this.project.categories.length ||
categories.some((value) => !this.project.categories.includes(value))
) {
data.categories = categories
}
if (
additionalCategories.length !==
this.project.additional_categories.length ||
additionalCategories.some(
(value) => !this.project.additional_categories.includes(value)
)
) {
data.additional_categories = additionalCategories
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
methods: {
toggleCategory(category) {
if (this.selectedTags.includes(category)) {
this.selectedTags = this.selectedTags.filter((x) => x !== category)
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category)
}
} else {
this.selectedTags.push(category)
}
},
toggleFeaturedCategory(category) {
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category)
} else {
this.featuredTags.push(category)
}
},
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
}
</script>
<style lang="scss" scoped>
.label__title {
margin-top: var(--spacing-card-bg);
svg {
vertical-align: top;
}
}
.category-list {
column-count: 4;
column-gap: var(--spacing-card-lg);
margin-bottom: var(--spacing-card-md);
.category-selector ::v-deep {
margin-bottom: 0.5rem;
.category-selector__label {
display: flex;
align-items: center;
.icon {
height: 1rem;
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
}
@media only screen and (max-width: 1250px) {
column-count: 3;
}
@media only screen and (max-width: 1024px) {
column-count: 4;
}
@media only screen and (max-width: 960px) {
column-count: 3;
}
@media only screen and (max-width: 750px) {
column-count: 2;
}
@media only screen and (max-width: 530px) {
column-count: 1;
}
}
</style>

View File

@ -32,7 +32,7 @@
</div> </div>
<div <div
v-for="version in filteredVersions" v-for="version in filteredVersions"
:key="version.id + '-new'" :key="version.id"
class="version-button button-transparent" class="version-button button-transparent"
@click=" @click="
$router.push( $router.push(

View File

@ -7,9 +7,9 @@
<NavStackItem link="/dashboard" label="Overview"> <NavStackItem link="/dashboard" label="Overview">
<DashboardIcon /> <DashboardIcon />
</NavStackItem> </NavStackItem>
<!-- <NavStackItem link="/dashboard/projects" label="Projects">--> <NavStackItem link="/dashboard/projects" label="Projects">
<!-- <ListIcon />--> <ListIcon />
<!-- </NavStackItem>--> </NavStackItem>
<!-- <NavStackItem link="/dashboard/analytics" label="Analytics">--> <!-- <NavStackItem link="/dashboard/analytics" label="Analytics">-->
<!-- <ChartIcon />--> <!-- <ChartIcon />-->
<!-- </NavStackItem>--> <!-- </NavStackItem>-->
@ -36,7 +36,7 @@ import NavStackItem from '~/components/ui/NavStackItem'
import DashboardIcon from '~/assets/images/utils/dashboard.svg?inline' import DashboardIcon from '~/assets/images/utils/dashboard.svg?inline'
// import ChartIcon from '~/assets/images/utils/chart.svg?inline' // import ChartIcon from '~/assets/images/utils/chart.svg?inline'
import CurrencyIcon from '~/assets/images/utils/currency.svg?inline' import CurrencyIcon from '~/assets/images/utils/currency.svg?inline'
// import ListIcon from '~/assets/images/utils/list.svg?inline' import ListIcon from '~/assets/images/utils/list.svg?inline'
const monetization = true const monetization = true
@ -48,7 +48,7 @@ export default {
DashboardIcon, DashboardIcon,
// ChartIcon, // ChartIcon,
CurrencyIcon, CurrencyIcon,
// ListIcon, ListIcon,
}, },
methods: { methods: {
hasMonetization() { hasMonetization() {

View File

@ -2,6 +2,10 @@
<div> <div>
<section class="universal-card"> <section class="universal-card">
<h2>Analytics</h2> <h2>Analytics</h2>
<p>You found a secret!</p>
<nuxt-link to="/frog" class="goto-link"
>Click here for fancy graphs!</nuxt-link
>
</section> </section>
</div> </div>
</template> </template>

View File

@ -2,8 +2,8 @@
<div> <div>
<section class="universal-card"> <section class="universal-card">
<h2>Overview</h2> <h2>Overview</h2>
<div class="metrics"> <div class="grid-display">
<div class="metric"> <div class="grid-display__item">
<div class="label">Total downloads</div> <div class="label">Total downloads</div>
<div class="value"> <div class="value">
{{ {{
@ -24,7 +24,7 @@
<!-- aria-hidden="true"--> <!-- aria-hidden="true"-->
<!-- /></NuxtLink>--> <!-- /></NuxtLink>-->
</div> </div>
<div class="metric"> <div class="grid-display__item">
<div class="label">Total followers</div> <div class="label">Total followers</div>
<div class="value"> <div class="value">
{{ {{
@ -47,7 +47,7 @@
<!-- aria-hidden="true"--> <!-- aria-hidden="true"-->
<!-- /></NuxtLink>--> <!-- /></NuxtLink>-->
</div> </div>
<div class="metric"> <div class="grid-display__item">
<div class="label">Total revenue</div> <div class="label">Total revenue</div>
<div class="value">{{ $formatMoney(payouts.all_time) }}</div> <div class="value">{{ $formatMoney(payouts.all_time) }}</div>
<span>{{ $formatMoney(payouts.last_month) }} this month</span> <span>{{ $formatMoney(payouts.last_month) }} this month</span>
@ -58,7 +58,7 @@
<!-- aria-hidden="true"--> <!-- aria-hidden="true"-->
<!-- /></NuxtLink>--> <!-- /></NuxtLink>-->
</div> </div>
<div class="metric"> <div class="grid-display__item">
<div class="label">Current balance</div> <div class="label">Current balance</div>
<div class="value"> <div class="value">
{{ $formatMoney($auth.user.payout_data.balance) }} {{ $formatMoney($auth.user.payout_data.balance) }}
@ -130,33 +130,4 @@ export default {
methods: {}, methods: {},
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
.metrics {
display: grid;
grid-gap: var(--spacing-card-md);
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
.metric {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: center;
background-color: var(--color-bg);
border-radius: var(--size-rounded-card);
padding: var(--spacing-card-lg);
gap: var(--spacing-card-md);
.label {
color: var(--color-heading);
font-weight: bold;
font-size: 1rem;
}
.value {
color: var(--color-text-dark);
font-weight: bold;
font-size: 2rem;
}
}
}
</style>

View File

@ -1,22 +1,664 @@
<template> <template>
<div> <div>
<Modal ref="editLinksModal" header="Edit links">
<div class="universal-modal links-modal">
<p>
Any links you specify below will be overwritten on each of the
selected projects. Any you leave blank will be ignored. You can clear
a link from all selected projects using the trash can button.
</p>
<section class="links">
<label
for="issue-tracker-input"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker</span>
</label>
<div class="input-group shrink-first">
<input
id="issue-tracker-input"
v-model="editLinks.issues.val"
:disabled="editLinks.issues.clear"
type="url"
:placeholder="
editLinks.issues.clear
? 'Existing link will be cleared'
: 'Enter a valid URL'
"
maxlength="2048"
/>
<button
v-tooltip="'Clear link'"
class="square-button label-button"
:data-active="editLinks.issues.clear"
@click="editLinks.issues.clear = !editLinks.issues.clear"
>
<TrashIcon />
</button>
</div>
<label
for="source-code-input"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code</span>
</label>
<div class="input-group shrink-first">
<input
id="source-code-input"
v-model="editLinks.source.val"
:disabled="editLinks.source.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.source.clear
? 'Existing link will be cleared'
: 'Enter a valid URL'
"
/>
<button
v-tooltip="'Clear link'"
class="square-button label-button"
:data-active="editLinks.source.clear"
@click="editLinks.source.clear = !editLinks.source.clear"
>
<TrashIcon />
</button>
</div>
<label
for="wiki-page-input"
title="A page containing information, documentation, and help for the project."
>
<span class="label__title">Wiki page</span>
</label>
<div class="input-group shrink-first">
<input
id="wiki-page-input"
v-model="editLinks.wiki.val"
:disabled="editLinks.wiki.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.wiki.clear
? 'Existing link will be cleared'
: 'Enter a valid URL'
"
/>
<button
v-tooltip="'Clear link'"
class="square-button label-button"
:data-active="editLinks.wiki.clear"
@click="editLinks.wiki.clear = !editLinks.wiki.clear"
>
<TrashIcon />
</button>
</div>
<label
for="discord-invite-input"
title="An invitation link to your Discord server."
>
<span class="label__title">Discord invite</span>
</label>
<div class="input-group shrink-first">
<input
id="discord-invite-input"
v-model="editLinks.discord.val"
:disabled="editLinks.discord.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.discord.clear
? 'Existing link will be cleared'
: 'Enter a valid Discord invite URL'
"
/>
<button
v-tooltip="'Clear link'"
class="square-button label-button"
:data-active="editLinks.discord.clear"
@click="editLinks.discord.clear = !editLinks.discord.clear"
>
<TrashIcon />
</button>
</div>
</section>
<p>
Changes will be applied to
<strong>{{ selectedProjects.length }}</strong> project{{
selectedProjects.length > 1 ? 's' : ''
}}.
</p>
<ul>
<li
v-for="project in selectedProjects.slice(
0,
editLinks.showAffected ? selectedProjects.length : 3
)"
:key="project.id"
>
{{ project.title }}
</li>
<li v-if="!editLinks.showAffected && selectedProjects.length > 3">
<strong>and {{ selectedProjects.length - 3 }} more...</strong>
</li>
</ul>
<Checkbox
v-if="selectedProjects.length > 3"
v-model="editLinks.showAffected"
:label="editLinks.showAffected ? 'Less' : 'More'"
description="Show all loaders"
:border="false"
:collapsing-toggle-style="true"
/>
<div class="push-right input-group">
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
<CrossIcon />
Cancel
</button>
<button
class="iconified-button brand-button"
@click="bulkEditLinks()"
>
<SaveIcon />
Save changes
</button>
</div>
</div>
</Modal>
<ModalCreation ref="modal_creation" />
<section class="universal-card"> <section class="universal-card">
<h2>Projects</h2> <div class="header__row">
<h2 class="header__title">Projects</h2>
<div class="input-group">
<button
class="iconified-button brand-button"
@click="$refs.modal_creation.show()"
>
<PlusIcon />
Create a project
</button>
</div>
</div>
<p v-if="projects.length < 1">
You don't have any projects yet. Click the green button above to begin.
</p>
<template v-else>
<p>You can edit multiple projects at once by selecting them below.</p>
<div class="input-group">
<button
class="iconified-button"
:disabled="selectedProjects.length === 0"
@click="$refs.editLinksModal.show()"
>
<EditIcon />
Edit links
</button>
<div class="push-right">
<div class="labeled-control-row">
Sort By
<Multiselect
v-model="sortBy"
:searchable="false"
class="small-select"
:options="['Name', 'Status', 'Type']"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@input="updateSort()"
></Multiselect>
</div>
</div>
</div>
<div class="grid-table">
<div class="grid-table__row grid-table__header">
<div>
<Checkbox
:value="selectedProjects === projects"
@input="
selectedProjects === projects
? (selectedProjects = [])
: (selectedProjects = projects)
"
/>
</div>
<div>Icon</div>
<div>Name</div>
<div>ID</div>
<div>Type</div>
<div>Status</div>
<div></div>
</div>
<div
v-for="project in projects"
:key="`project-${project.id}`"
class="grid-table__row"
>
<div>
<Checkbox
:disabled="
(project.permissions & EDIT_DETAILS) === EDIT_DETAILS
"
:value="selectedProjects.includes(project)"
@input="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter(
(it) => it !== project
))
: selectedProjects.push(project)
"
/>
</div>
<div>
<nuxt-link
tabindex="-1"
:to="`/${project.project_type}/${project.slug}`"
>
<Avatar
:src="project.icon_url"
aria-hidden="true"
:alt="'Icon for ' + project.title"
no-shadow
/>
</nuxt-link>
</div>
<div>
<span class="project-title">
<IssuesIcon
v-if="project.moderator_message"
v-tooltip="
'Project has a message from the moderators. View the project to see more.'
"
aria-label="Project has a message from the moderators. View the project to see more."
/>
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/${project.project_type}/${project.slug}`"
>
{{ project.title }}
</nuxt-link>
</span>
</div>
<div>
<CopyCode :text="project.id" />
</div>
<div>
{{ $formatProjectType(project.project_type) }}
</div>
<div>
<Badge
v-if="project.status"
:type="project.status"
class="status"
/>
</div>
<div>
<nuxt-link
class="square-button"
:to="`/${project.project_type}/${project.slug}/settings`"
>
<SettingsIcon />
</nuxt-link>
</div>
</div>
</div>
</template>
</section> </section>
</div> </div>
</template> </template>
<script> <script>
import Multiselect from 'vue-multiselect'
import Badge from '~/components/ui/Badge.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
import Modal from '~/components/ui/Modal.vue'
// import ModalConfirm from '~/components/ui/ModalConfirm.vue'
import Avatar from '~/components/ui/Avatar.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import IssuesIcon from '~/assets/images/utils/issues.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
export default { export default {
components: {}, components: {
data() { Avatar,
return {} Badge,
SettingsIcon,
TrashIcon,
Checkbox,
IssuesIcon,
PlusIcon,
CrossIcon,
EditIcon,
SaveIcon,
Modal,
// ModalConfirm,
ModalCreation,
Multiselect,
CopyCode,
},
data() {
return {
projects: [],
versions: [],
selectedProjects: [],
sortBy: 'Name',
editLinks: {
showAffected: false,
source: {
val: '',
clear: false,
},
discord: {
val: '',
clear: false,
},
wiki: {
val: '',
clear: false,
},
issues: {
val: '',
clear: false,
},
},
}
},
fetch() {
this.projects = this.$user.projects
this.updateSort()
}, },
fetch() {},
head: { head: {
title: 'Projects - Modrinth', title: 'Projects - Modrinth',
}, },
methods: {}, created() {
this.UPLOAD_VERSION = 1 << 0
this.DELETE_VERSION = 1 << 1
this.EDIT_DETAILS = 1 << 2
this.EDIT_BODY = 1 << 3
this.MANAGE_INVITES = 1 << 4
this.REMOVE_MEMBER = 1 << 5
this.EDIT_MEMBER = 1 << 6
this.DELETE_PROJECT = 1 << 7
},
mounted() {},
methods: {
updateSort() {
switch (this.sortBy) {
case 'Name':
this.projects = this.projects.slice().sort((a, b) => {
if (a.title < b.title) {
return -1
}
if (a.title > b.title) {
return 1
}
return 0
})
break
case 'Status':
this.projects = this.projects.slice().sort((a, b) => {
if (a.status < b.status) {
return -1
}
if (a.status > b.status) {
return 1
}
return 0
})
break
case 'Type':
this.projects = this.projects.slice().sort((a, b) => {
if (a.project_type < b.project_type) {
return -1
}
if (a.project_type > b.project_type) {
return 1
}
return 0
})
break
default:
break
}
},
async bulkEditLinks() {
try {
const baseData = {
issues_url:
!this.editLinks.issues.clear &&
this.editLinks.issues.val.trim() !== ''
? this.editLinks.issues.val
: null,
source_url:
!this.editLinks.source.clear &&
this.editLinks.source.val.trim() !== ''
? this.editLinks.source.val
: null,
wiki_url:
!this.editLinks.wiki.clear && this.editLinks.wiki.val.trim() !== ''
? this.editLinks.wiki.val
: null,
discord_url:
!this.editLinks.discord.clear &&
this.editLinks.discord.val.trim() !== ''
? this.editLinks.discord.val
: null,
}
await this.$axios.patch(
`projects?ids=${JSON.stringify(
this.selectedProjects.map((x) => x.id)
)}`,
baseData,
this.$defaultHeaders()
)
this.$refs.editLinksModal.hide()
this.$notify({
group: 'main',
title: 'Success',
text: "Bulk edited selected project's links.",
type: 'success',
})
this.selectedProjects = []
} catch (e) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: e,
type: 'error',
})
}
},
},
} }
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.grid-table {
display: grid;
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
.grid-table__row {
display: contents;
> div {
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing-card-sm);
// Left edge of table
&:first-child {
padding-left: var(--spacing-card-bg);
}
// Right edge of table
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
.grid-table__row {
display: grid;
grid-template: 'checkbox icon name type settings' 'checkbox icon id status settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(6) {
grid-area: status;
padding-top: 0;
}
:nth-child(7) {
grid-area: settings;
}
}
.grid-table__header {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5),
:nth-child(6) {
display: none;
}
}
}
@media screen and (max-width: 560px) {
.grid-table__row {
display: grid;
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
padding-top: 0;
}
}
.grid-table__header {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
}
.project-title {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
svg {
color: var(--color-special-orange);
}
}
.status {
margin-top: var(--spacing-card-xs);
}
.hover-link:hover {
text-decoration: underline;
}
.labeled-control-row {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
align-items: center;
gap: var(--spacing-card-md);
}
.small-select {
width: -moz-fit-content;
width: fit-content;
}
.label-button[data-active='true'] {
--background-color: var(--color-special-red);
--text-color: var(--color-brand-inverted);
}
.links-modal {
.links {
display: grid;
gap: var(--spacing-card-sm);
grid-template-columns: 1fr 2fr;
.input-group {
flex-wrap: nowrap;
}
@media screen and (max-width: 530px) {
grid-template-columns: 1fr;
.input-group {
flex-wrap: wrap;
}
}
}
ul {
margin: 0 0 var(--spacing-card-sm) 0;
}
}
</style>

View File

@ -22,7 +22,7 @@
> >
</p> </p>
<div v-if="enrolled" class="buttons"> <div v-if="enrolled" class="input-group">
<button <button
class="iconified-button brand-button" class="iconified-button brand-button"
@click="$refs.modal_transfer.show()" @click="$refs.modal_transfer.show()"
@ -122,10 +122,4 @@ strong {
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: 500; font-weight: 500;
} }
.buttons {
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
}
</style> </style>

View File

@ -1,5 +1,4 @@
<template> <template>
<div class="normal-page">
<div class="card"> <div class="card">
<h1>Frog</h1> <h1>Frog</h1>
<p>You've been frogged! 🐸</p> <p>You've been frogged! 🐸</p>
@ -8,7 +7,6 @@
alt="a photorealistic painting of a frog labyrinth" alt="a photorealistic painting of a frog labyrinth"
/> />
</div> </div>
</div>
</template> </template>
<script> <script>
@ -19,11 +17,17 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.card { .card {
width: 100%; width: calc(100% - 2 * var(--spacing-card-md));
max-width: 1280px;
margin-inline: auto;
text-align: center; text-align: center;
box-sizing: border-box;
margin-block: var(--spacing-card-md);
} }
img { img {
margin-block: 0 1.5rem; margin-block: 0 1.5rem;
width: 60%;
max-width: 40rem;
} }
</style> </style>

View File

@ -91,17 +91,24 @@
> >
<button <button
class="iconified-button" class="iconified-button"
@click="setProjectStatus(project, 'approved')" @click="
setProjectStatus(
project,
project.requested_status
? project.requested_status
: 'approved'
)
"
> >
<CheckIcon /> <CheckIcon />
Approve Approve
</button> </button>
<button <button
class="iconified-button" class="iconified-button"
@click="setProjectStatus(project, 'unlisted')" @click="setProjectStatus(project, 'withheld')"
> >
<UnlistIcon /> <UnlistIcon />
Unlist Withhold
</button> </button>
<button <button
class="iconified-button" class="iconified-button"

View File

@ -12,22 +12,23 @@
:label="NOTIFICATION_TYPES[type]" :label="NOTIFICATION_TYPES[type]"
> >
</NavStackItem> </NavStackItem>
</NavStack>
<h3>Manage</h3> <h3>Manage</h3>
<div class="input-group"> <NavStackItem
<NuxtLink class="iconified-button" to="/settings/follows"> link="/settings/follows"
label="Followed projects"
chevron
>
<SettingsIcon /> <SettingsIcon />
Followed projects </NavStackItem>
</NuxtLink> <NavStackItem
<button
v-if="$user.notifications.length > 0" v-if="$user.notifications.length > 0"
class="iconified-button danger-button" :action="clearNotifications"
@click="clearNotifications" label="Clear all"
danger
> >
<ClearIcon /> <ClearIcon />
Clear all </NavStackItem>
</button> </NavStack>
</div>
</aside> </aside>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">

View File

@ -46,26 +46,9 @@
</h3> </h3>
<SearchFilter <SearchFilter
v-for="category in categories v-for="category in categories.filter(
.filter((x) => x.project_type === projectType.actual) (x) => x.project_type === projectType.actual
.sort((a, b) => { )"
if (header === 'resolutions') {
return (
a.name.replace(/\D/g, '') - b.name.replace(/\D/g, '')
)
} else if (header === 'performance impact') {
const x = [
'potato',
'low',
'medium',
'high',
'screenshot',
]
return x.indexOf(a.name) - x.indexOf(b.name)
}
return 0
})"
:key="category.name" :key="category.name"
:active-filters="facets" :active-filters="facets"
:display-name="$formatCategory(category.name)" :display-name="$formatCategory(category.name)"
@ -580,7 +563,7 @@ export default {
categoriesMap() { categoriesMap() {
const categories = {} const categories = {}
for (const category of this.$tag.categories) { for (const category of this.$sortedCategories) {
if (categories[category.header]) { if (categories[category.header]) {
categories[category.header].push(category) categories[category.header].push(category)
} else { } else {
@ -588,17 +571,11 @@ export default {
} }
} }
const newVals = Object.keys(categories) const newVals = Object.keys(categories).reduce((obj, key) => {
.sort()
.reduce((obj, key) => {
obj[key] = categories[key] obj[key] = categories[key]
return obj return obj
}, {}) }, {})
for (const header of Object.keys(categories)) {
newVals[header].sort((a, b) => a.name.localeCompare(b.name))
}
return newVals return newVals
}, },
}, },

View File

@ -15,11 +15,7 @@
<NavStackItem link="/settings/follows" label="Followed projects"> <NavStackItem link="/settings/follows" label="Followed projects">
<HeartIcon /> <HeartIcon />
</NavStackItem> </NavStackItem>
<NavStackItem <NavStackItem link="/settings/monetization" label="Monetization">
link="/settings/monetization"
label="Monetization"
beta
>
<CurrencyIcon /> <CurrencyIcon />
</NavStackItem> </NavStackItem>
</template> </template>

View File

@ -64,7 +64,7 @@
<div <div
v-for="projectType in listTypes" v-for="projectType in listTypes"
:key="projectType.id + '-display-mode-selector'" :key="projectType.id + '-display-mode-selector'"
class="adjacent-input small" class="adjacent-input"
> >
<label :for="projectType.id + '-search-display-mode'"> <label :for="projectType.id + '-search-display-mode'">
<span class="label__title">{{ projectType.name }} display mode</span> <span class="label__title">{{ projectType.name }} display mode</span>

View File

@ -25,7 +25,7 @@
Program. Setup a method of receiving payments below to enable Program. Setup a method of receiving payments below to enable
monetization. monetization.
</p> </p>
<div class="enroll extend-styling"> <div class="enroll universal-body">
<Chips <Chips
v-model="selectedWallet" v-model="selectedWallet"
:starting-value="selectedWallet" :starting-value="selectedWallet"

View File

@ -156,7 +156,7 @@
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<Advertisement type="banner" small-screen="square" /> <Advertisement type="banner" small-screen="square" />
<nav class="card user-navigation"> <nav class="navigation-card">
<NavRow <NavRow
query="type" query="type"
:links="[ :links="[
@ -567,15 +567,6 @@ export default {
} }
} }
.user-navigation {
align-items: center;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
row-gap: 0.5rem;
padding-right: var(--spacing-card-bg);
}
.sidebar { .sidebar {
padding-top: 2.5rem; padding-top: 2.5rem;
} }

View File

@ -31,11 +31,14 @@ export default (ctx, inject) => {
inject('formatProjectType', formatProjectType) inject('formatProjectType', formatProjectType)
inject('formatCategory', formatCategory) inject('formatCategory', formatCategory)
inject('formatCategoryHeader', formatCategoryHeader) inject('formatCategoryHeader', formatCategoryHeader)
inject('formatProjectStatus', formatProjectStatus)
inject('computeVersions', (versions) => { inject('computeVersions', (versions) => {
const visitedVersions = [] const visitedVersions = []
const returnVersions = [] const returnVersions = []
for (const version of versions.reverse()) { for (const version of versions.sort(
(a, b) => ctx.$dayjs(a.date_published) - ctx.$dayjs(b.date_published)
)) {
if (visitedVersions.includes(version.version_number)) { if (visitedVersions.includes(version.version_number)) {
visitedVersions.push(version.version_number) visitedVersions.push(version.version_number)
version.displayUrlEnding = version.id version.displayUrlEnding = version.id
@ -47,7 +50,9 @@ export default (ctx, inject) => {
returnVersions.push(version) returnVersions.push(version)
} }
return returnVersions.reverse().map((version, index) => { return returnVersions
.reverse()
.map((version, index) => {
const nextVersion = returnVersions[index + 1] const nextVersion = returnVersions[index + 1]
if ( if (
nextVersion && nextVersion &&
@ -59,6 +64,9 @@ export default (ctx, inject) => {
return { duplicate: false, ...version } return { duplicate: false, ...version }
} }
}) })
.sort(
(a, b) => ctx.$dayjs(b.date_published) - ctx.$dayjs(a.date_published)
)
}) })
inject('getProjectTypeForDisplay', (type, categories) => { inject('getProjectTypeForDisplay', (type, categories) => {
if (type === 'mod') { if (type === 'mod') {
@ -115,6 +123,26 @@ export default (ctx, inject) => {
} }
}) })
inject('cycleValue', cycleValue) inject('cycleValue', cycleValue)
const sortedCategories = ctx.store.state.tag.categories
.slice()
.sort((a, b) => {
const headerCompare = a.header.localeCompare(b.header)
if (headerCompare !== 0) {
return headerCompare
}
if (a.header === 'resolutions' && b.header === 'resolutions') {
return a.name.replace(/\D/g, '') - b.name.replace(/\D/g, '')
} else if (
a.header === 'performance impact' &&
b.header === 'performance impact'
) {
const x = ['potato', 'low', 'medium', 'high', 'screenshot']
return x.indexOf(a.name) - x.indexOf(b.name)
}
return 0
})
inject('sortedCategories', sortedCategories)
} }
export const formatNumber = (number) => { export const formatNumber = (number) => {
@ -158,7 +186,7 @@ export const formatBytes = (bytes, decimals = 2) => {
} }
export const capitalizeString = (name) => { export const capitalizeString = (name) => {
return name.charAt(0).toUpperCase() + name.slice(1) return name ? name.charAt(0).toUpperCase() + name.slice(1) : name
} }
export const formatWallet = (name) => { export const formatWallet = (name) => {
@ -205,6 +233,10 @@ export const formatCategory = (name) => {
return 'PBR' return 'PBR'
} else if (name === 'datapack') { } else if (name === 'datapack') {
return 'Data Pack' return 'Data Pack'
} else if (name === 'colored-lighting') {
return 'Colored Lighting'
} else if (name === 'optifine') {
return 'OptiFine'
} }
return capitalizeString(name) return capitalizeString(name)
@ -214,6 +246,16 @@ export const formatCategoryHeader = (name) => {
return capitalizeString(name) return capitalizeString(name)
} }
export const formatProjectStatus = (name) => {
if (name === 'approved') {
return 'Listed'
} else if (name === 'processing') {
return 'Under review'
}
return capitalizeString(name)
}
export const formatVersions = (versionArray, store) => { export const formatVersions = (versionArray, store) => {
const allVersions = store.state.tag.gameVersions.slice().reverse() const allVersions = store.state.tag.gameVersions.slice().reverse()
const allReleases = allVersions.filter((x) => x.version_type === 'release') const allReleases = allVersions.filter((x) => x.version_type === 'release')

View File

@ -89,14 +89,14 @@ export const actions = {
async fetchProjects({ commit, rootState }) { async fetchProjects({ commit, rootState }) {
if (rootState.auth.user && rootState.auth.user.id) { if (rootState.auth.user && rootState.auth.user.id) {
try { try {
const follows = ( const projects = (
await this.$axios.get( await this.$axios.get(
`user/${rootState.auth.user.id}/follows`, `user/${rootState.auth.user.id}/projects`,
rootState.auth.headers rootState.auth.headers
) )
).data ).data
commit('SET_FOLLOWS', follows) commit('SET_PROJECTS', projects)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }