Add initial support for the v2 of the API (Still WIP) (#250)

* Functionally implement modpacks

* Add LogoAnimated to logo license

* Fix eslint errors

* Add `z-index: 20` to user dropdown (#287)

* Fix pages not working, add changelog page, redesign versions page

* Update theme colors, add OLED theme, update some project creation text. (#292)

* Update theme colors, add OLED theme, update some project creation text.

* Make summary normal text color

* Update favicons, update logos to use dynamic colors, updated filters panel a bit

* Update wording from #250

* Version page rework

* Manually apply some commits from master, other minor v2 fixes (#296)

* Homepage styling improvements (#285)

* Add border radius to video + example code colors

* Change color + allow overflow scroll

* Minor v2 fixes

- Makes multiple loaders display correctly (used to be `Fabric,Forge` is now `Fabric, Forge`
- Fix oopses in #292
- Allow .jar and .zip in file prompt
- Apply 30cbd3a6c372940d1e86cc8134d0dfc7e8e5ee9c to pages/create/project.vue
- Display `fabric, forge` instead of broken icons on pages/create/project.vue

* Markdown styling fixes (#268)

* Add table color variables (+ prettier fixes)

* Add details and table styling to .markdown-body

* Add indexing meta value depending on the status of the mod. (#261)

* General UI Improvement (again) (#255)

* Add and fix some stuff

* Add warning when leaving to `mod/create`

* Fix mods/create not working

* Fix a bug & add improvements to a couple moderation aspects (#278)

This PR fixes reports on the moderation dashboard going to `/dashboard/mod/_id` instead of to `/mod/_id`.
It also allows the ability for moderators to unlist mods in the queue from the frontend instead of having to do it via the backend.
![image](https://i.imgur.com/x8shSVn.png)
Unlisted mods should have the ability to resubmit for approval, so I've also changed "Submit for Review" to "Submit for approval", allowing unlisted mods to do that as well.
![image](https://i.imgur.com/OC8Vyfo.png)

* Add project guidelines to Terms page (#275)

* Add project guidelines to Terms page

This adds the project guidelines as outlined [here](https://discord.com/channels/734077874708938864/734077874708938867/806556531491471368).
NOTE: I've made a few tweaks in wording to accommodate this format, so this is not an exact copy.

* Move rules to its own page

* Allow users to login from search page when it is rendered serverside (#272)

* Change `this.$route.fullPath` → `this.$route.path`

* Closes modrinth/knossos#256

* Wrap mod icon and title in link (#273)

* Wrap mod icon and title in link

* Fixes #218

* Editor's note

    Skipped #249 (search was rewritten), #266 (couldn't figure out how to apply it), #270 (didn't seem to apply properly), #252 (manually merged in with #292), #262 (superceded by #270), #282, #271, #277, #283, and #281 (those five didn't get wiped)

Co-authored-by: venashial <venashial.levo@aleeas.com>
Co-authored-by: Redblueflame <contact@redblueflame.com>
Co-authored-by: Johan Novak <wickedtree@wickedtree.codes>

* SSR descriptions, version edit page

* Working version editing + dependency management (besides files)

* Version create page, file functionality

* Fix some issues with the version page

* More versions page fixes

* Project gallery

* Box shadows, user profile page, WIP header

* Finish user dashboard

* Finish search and fix minor issues

* Moderator page + messages, notifications page

* Fix dropdown menu, fix XSS, fix team members page

* Change doc url on main page (#309)

* Re-Fix docs url (#313)

* Clean up. Part 1: Fix immediate problems (#316)

* Clean up tabs and cards CSS a little

* Fix project page; Remove bad styles from search

* Yeet and flatten lots of styles; fix font sizes

* Restyle search; fix moderation

* Fix profile page

* Remove injected SCSS entirely

* Fix a mobile layout overflowing

* Apiv2-support fixes (#320)

* Fix member user_id -> user.id

* Fix incorrect report redirect

* Change theme switcher from button to multiselect

* Fix remaining items

Co-authored-by: Jai A <jaiagr+gpg@pm.me>

* Fix bugs

* Full mobile support, update create project page, fix various bugs

* New Dark Mode brand colors (#325)

* Use "color-brand-hover" for auth-prompt when hover over

* New dark mode brand colors

* Fix new version featured bug

* Remove old home page, other fixes

* Fix error when merging

* Fix prettier error :(

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: venashial <venashial.levo@aleeas.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Emma <emmaffle@modrinth.com>
Co-authored-by: Johan Novak <wickedtree@wickedtree.codes>
Co-authored-by: Jai A <jaiagr@pm.me>
Co-authored-by: Mysterious_Dev <40738104+Mysterious-Dev@users.noreply.github.com>
Co-authored-by: Mikhail Oleynikov <contact@falseresync.ru>
Co-authored-by: Christian Popov <30723811+Xrey274@users.noreply.github.com>
This commit is contained in:
Redblueflame
2022-01-09 23:19:27 +01:00
committed by GitHub
parent c518f373df
commit a2266adb3f
167 changed files with 18841 additions and 18188 deletions

977
pages/_type/_id.vue Normal file
View File

@@ -0,0 +1,977 @@
<template>
<div class="page-container">
<div class="page-contents">
<section class="project-info">
<div class="header card">
<nuxt-link
:to="
'/' +
project.project_type +
'/' +
(project.slug ? project.slug : project.id)
"
>
<img
class="icon"
:src="
project.icon_url
? project.icon_url
: 'https://cdn.modrinth.com/placeholder.svg?inline'
"
alt="project - icon"
/></nuxt-link>
<nuxt-link
:to="
'/' +
project.project_type +
'/' +
(project.slug ? project.slug : project.id)
"
>
<h1 class="title">{{ project.title }}</h1>
</nuxt-link>
<div
v-if="
project.client_side === 'optional' &&
project.server_side === 'optional'
"
class="side-descriptor"
>
<InfoIcon />
Universal {{ project.project_type }}
</div>
<div
v-else-if="
(project.client_side === 'optional' ||
project.client_side === 'required') &&
(project.server_side === 'optional' ||
project.server_side === 'unsupported')
"
class="side-descriptor"
>
<InfoIcon />
Client {{ project.project_type }}
</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 />
Server {{ project.project_type }}
</div>
<p class="description">
{{ project.description }}
</p>
<Categories :categories="project.categories" class="categories" />
<hr />
<div class="stats">
<span class="stat">{{ formatNumber(project.downloads) }}</span>
<span class="label">downloads</span>
<span class="stat">{{ formatNumber(project.followers) }}</span>
<span class="label">followers</span>
</div>
<div class="dates">
<div class="date">
<CalendarIcon />
<span class="label">Created</span>
<span class="value">{{
$dayjs(project.published).fromNow()
}}</span>
</div>
<div class="date">
<UpdateIcon />
<span class="label">Updated</span>
<span class="value">{{ $dayjs(project.updated).fromNow() }}</span>
</div>
</div>
<hr />
<div class="buttons">
<nuxt-link
v-if="$auth.user"
:to="`/create/report?id=${project.id}&t=project`"
class="iconified-button"
>
<ReportIcon />
Report
</nuxt-link>
<button
v-if="
$auth.user && !$user.follows.find((x) => x.id === project.id)
"
class="iconified-button"
@click="$store.dispatch('user/followProject', project)"
>
<FollowIcon />
Follow
</button>
<button
v-if="
$auth.user && $user.follows.find((x) => x.id === project.id)
"
class="iconified-button"
@click="$store.dispatch('user/unfollowProject', project)"
>
<FollowIcon fill="currentColor" />
Unfollow
</button>
</div>
</div>
<div
v-if="
currentMember &&
(project.status === 'processing' ||
(project.moderator_message &&
(project.moderator_message.message ||
project.moderator_message.body)))
"
class="card"
>
<h3>Project status</h3>
<div class="status-info"></div>
<p>
Your project is currently:
<VersionBadge
v-if="project.status === 'approved'"
color="green"
:type="project.status"
/>
<VersionBadge
v-else-if="
project.status === 'processing' || project.status === 'archived'
"
color="yellow"
:type="project.status"
/>
<VersionBadge
v-else-if="project.status === 'rejected'"
color="red"
:type="project.status"
/>
<VersionBadge v-else color="gray" :type="project.status" />
</p>
<div class="message">
<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 project rules!
</p>
<p v-if="project.status === 'draft'">
Your project is currently not viewable by people who are not part
of your team. If your project is ready for review, click the
button below to make your mod public!
</p>
<p v-if="project.moderator_message">
{{ project.moderator_message.message }}
</p>
<div
v-if="project.moderator_message && project.moderator_message.body"
v-highlightjs
class="markdown-body"
v-html="$xss($md.render(project.moderator_message.body))"
></div>
</div>
<div class="buttons">
<button
v-if="
project.status !== 'processing' && project.status !== 'approved'
"
class="iconified-button"
@click="submitForReview"
>
Resubmit for approval
</button>
<button
v-if="project.status === 'approved'"
class="iconified-button"
@click="clearMessage"
>
Clear message
</button>
</div>
</div>
<div class="card">
<template
v-if="
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url
"
>
<h3>External resources</h3>
<div class="links">
<a
v-if="project.issues_url"
:href="project.issues_url"
class="title"
target="_blank"
>
<IssuesIcon />
<span>Issues</span>
</a>
<a
v-if="project.source_url"
:href="project.source_url"
class="title"
target="_blank"
>
<CodeIcon />
<span>Source</span>
</a>
<a
v-if="project.wiki_url"
:href="project.wiki_url"
class="title"
target="_blank"
>
<WikiIcon />
<span>Wiki</span>
</a>
<a
v-if="project.discord_url"
:href="project.discord_url"
target="_blank"
>
<DiscordIcon
v-if="$colorMode.value === 'light'"
class="shrink"
/>
<DiscordIconWhite v-else class="shrink" />
<span>Discord</span>
</a>
<a
v-for="(donation, index) in project.donation_urls"
:key="index"
:href="donation.url"
target="_blank"
>
<BuyMeACoffeeLogo
v-if="donation.id === 'bmac' && $colorMode.value === 'light'"
/>
<BuyMeACoffeeLogoWhite
v-else-if="
donation.id === 'bmac' && $colorMode.value === 'dark'
"
/>
<img
v-else-if="
donation.id === 'patreon' && $colorMode.value === 'light'
"
class="shrink"
alt="patreon"
src="~/assets/images/external/patreon.png"
/>
<img
v-else-if="
donation.id === 'patreon' && $colorMode.value === 'dark'
"
class="shrink"
alt="patreon"
src="~/assets/images/external/patreon-white.png"
/>
<img
v-else-if="
donation.id === 'paypal' && $colorMode.value === 'light'
"
class="shrink"
alt="paypal"
src="~/assets/images/external/paypal.png"
/>
<img
v-else-if="
donation.id === 'paypal' && $colorMode.value === 'dark'
"
class="shrink"
alt="paypal"
src="~/assets/images/external/paypal-white.png"
/>
<img
v-else-if="
donation.id === 'ko-fi' && $colorMode.value === 'light'
"
alt="kofi"
src="~/assets/images/external/kofi.png"
/>
<img
v-else-if="
donation.id === 'ko-fi' && $colorMode.value === 'dark'
"
alt="kofi"
src="~/assets/images/external/kofi-white.png"
/>
<FollowIcon v-else-if="donation.id === 'github'" />
<UnknownIcon v-else />
<span v-if="donation.id === 'bmac'">Buy Me a Coffee</span>
<span v-else-if="donation.id === 'patreon'">Patreon</span>
<span v-else-if="donation.id === 'paypal'">PayPal</span>
<span v-else-if="donation.id === 'ko-fi'">Ko-fi</span>
<span v-else-if="donation.id === 'github'"
>GitHub Sponsors</span
>
<span v-else>Donate</span>
</a>
</div>
<hr />
</template>
<template v-if="featuredVersions.length > 0">
<h3>Featured versions</h3>
<div
v-for="version in featuredVersions"
:key="version.id"
class="featured-version"
>
<a
:href="findPrimary(version).url"
class="download"
@click.prevent="
downloadFile(
findPrimary(version).hashes.sha1,
findPrimary(version).url
)
"
>
<DownloadIcon />
</a>
<div class="info">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURIComponent(version.version_number)}`"
class="top"
>
{{ version.name }}
</nuxt-link>
<div
v-if="version.game_versions.length > 0"
class="game-version item"
>
{{
version.loaders
.map((x) => x.charAt(0).toUpperCase() + x.slice(1))
.join(', ')
}}
{{ version.game_versions[version.game_versions.length - 1] }}
</div>
<VersionBadge
v-if="version.version_type === 'release'"
type="release"
color="green"
/>
<VersionBadge
v-else-if="version.version_type === 'beta'"
type="beta"
color="yellow"
/>
<VersionBadge
v-else-if="version.version_type === 'alpha'"
type="alpha"
color="red"
/>
</div>
</div>
<hr />
</template>
<h3>Project members</h3>
<div
v-for="member in members"
:key="member.user.id"
class="team-member columns"
>
<img :src="member.avatar_url" alt="profile-picture" />
<div class="member-info">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p>{{ member.name }}</p>
</nuxt-link>
<p class="role">{{ member.role }}</p>
</div>
</div>
<hr />
<h3>Technical information</h3>
<div class="infos">
<div class="info">
<div class="key">License</div>
<div class="value uppercase">
<a class="text-link" :href="project.license.url || null">{{
project.license.id
}}</a>
</div>
</div>
<div class="info">
<div class="key">Client side</div>
<div class="value">
{{ project.client_side }}
</div>
</div>
<div class="info">
<div class="key">Server side</div>
<div class="value">
{{ project.server_side }}
</div>
</div>
<div class="info">
<div class="key">Project ID</div>
<div class="value">
{{ project.id }}
</div>
</div>
</div>
</div>
<Advertisement
v-if="project.status === 'approved' || project.status === 'unlisted'"
type="square"
small-screen="destroy"
/>
</section>
<div class="content">
<div class="project-main">
<div class="card styled-tabs">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}`"
class="tab"
exact
>
<span>Description</span>
</nuxt-link>
<nuxt-link
v-if="project.gallery.length > 0 || currentMember"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/gallery`"
class="tab"
>
<span>Gallery</span>
</nuxt-link>
<nuxt-link
v-if="project.versions.length > 0"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/changelog`"
class="tab"
>
<span>Changelog</span>
</nuxt-link>
<nuxt-link
v-if="project.versions.length > 0"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/versions`"
class="tab"
>
<span>Versions</span>
</nuxt-link>
<nuxt-link
v-if="currentMember"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings`"
class="tab"
>
<span>Settings</span>
</nuxt-link>
</div>
<Advertisement
v-if="
project.status === 'approved' || project.status === 'unlisted'
"
type="banner"
small-screen="square"
ethical-ads-small
ethical-ads-big
/>
<div class="project-content">
<NuxtChild
:project.sync="project"
:versions.sync="versions"
:featured-versions.sync="featuredVersions"
:members.sync="members"
:current-member="currentMember"
:all-members.sync="allMembers"
:dependencies.sync="dependencies"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
import UpdateIcon from '~/assets/images/utils/updated.svg?inline'
import CodeIcon from '~/assets/images/sidebar/mod.svg?inline'
import ReportIcon from '~/assets/images/utils/report.svg?inline'
import FollowIcon 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 WikiIcon from '~/assets/images/utils/wiki.svg?inline'
import DiscordIcon from '~/assets/images/external/discord.svg?inline'
import DiscordIconWhite from '~/assets/images/external/discord-white.svg?inline'
import BuyMeACoffeeLogo from '~/assets/images/external/bmac.svg?inline'
import BuyMeACoffeeLogoWhite from '~/assets/images/external/bmac-white.svg?inline'
import UnknownIcon from '~/assets/images/utils/unknown.svg?inline'
import Advertisement from '~/components/ads/Advertisement'
import VersionBadge from '~/components/ui/Badge'
import Categories from '~/components/ui/search/Categories'
export default {
components: {
VersionBadge,
Advertisement,
IssuesIcon,
DownloadIcon,
CalendarIcon,
UpdateIcon,
CodeIcon,
ReportIcon,
FollowIcon,
InfoIcon,
WikiIcon,
DiscordIcon,
DiscordIconWhite,
BuyMeACoffeeLogo,
BuyMeACoffeeLogoWhite,
UnknownIcon,
Categories,
},
async asyncData(data) {
const projectTypes = ['mod', 'modpack']
try {
if (
!data.params.id ||
!projectTypes.includes(data.params.type.toLowerCase())
) {
data.error({
statusCode: 404,
message: 'The page could not be found',
})
return
}
const [project, members, dependencies, versions, featuredVersions] = (
await Promise.all([
data.$axios.get(`project/${data.params.id}`, data.$auth.headers),
data.$axios.get(
`project/${data.params.id}/members`,
data.$auth.headers
),
data.$axios.get(`project/${data.params.id}/dependencies`),
data.$axios.get(`project/${data.params.id}/version`),
data.$axios.get(`project/${data.params.id}/version?featured=true`),
])
).map((it) => it.data)
if (project.project_type !== data.params.type) {
data.error({
statusCode: 404,
message: 'Project not found',
})
return
}
members.forEach((it, index) => {
members[index].avatar_url = it.user.avatar_url
members[index].name = it.user.username
})
const currentMember = data.$auth.user
? members.find((x) => x.user.id === data.$auth.user.id)
: null
if (project.body_url && !project.body) {
project.body = (await data.$axios.get(project.body_url)).data
}
return {
project,
versions,
featuredVersions,
members: members.filter((x) => x.accepted),
allMembers: members,
currentMember,
dependencies,
}
} catch {
data.error({
statusCode: 404,
message: 'Project not found',
})
}
},
head() {
return {
title: `${this.project.title} - ${
this.project.project_type.charAt(0).toUpperCase() +
this.project.project_type.slice(1)
}s - Modrinth`,
meta: [
{
hid: 'og:type',
name: 'og:type',
content: 'website',
},
{
hid: 'og:title',
name: 'og:title',
content: this.project.title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: this.project.title,
},
{
hid: 'og:description',
name: 'og:description',
content: this.project.description,
},
{
hid: 'description',
name: 'description',
content: `${this.project.title}: ${this.project.description} View other minecraft mods on Modrinth today! Modrinth is a new and modern Minecraft modding platform supporting both the Forge and Fabric mod loaders.`,
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/${this.project.project_type}/${this.project.id}`,
},
{
hid: 'og:image',
name: 'og:image',
content: this.project.icon_url
? this.project.icon_url
: 'https://cdn.modrinth.com/placeholder.png',
},
{
hid: 'robots',
name: 'robots',
content: this.project.status !== 'approved' ? 'noindex' : 'all',
},
],
}
},
methods: {
formatNumber(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
},
findPrimary(version) {
let file = version.files.find((x) => x.primary)
if (!file) {
file = version.files[0]
}
if (!file) {
file = { url: `/project/${this.project.id}/version/${version.id}` }
}
return file
},
async downloadFile(hash, url) {
await this.$axios.get(`version_file/${hash}/download`)
const elem = document.createElement('a')
elem.download = hash
elem.href = url
elem.click()
},
async clearMessage() {
this.$nuxt.$loading.start()
try {
await this.$axios.patch(
`project/${this.currentProject.id}`,
{
moderation_message: null,
moderation_message_body: null,
},
this.$auth.headers
)
this.project.moderator_message = null
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
async submitForReview() {
this.$nuxt.$loading.start()
try {
await this.$axios.patch(
`project/${this.currentProject.id}`,
{
status: 'processing',
},
this.$auth.headers
)
this.project.status = 'processing'
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
},
}
</script>
<style lang="scss" scoped>
hr {
background-color: var(--color-divider);
border: none;
color: var(--color-divider);
height: 1px;
margin: var(--spacing-card-bg) 0;
}
.header {
.icon {
width: 6rem;
height: 6rem;
object-fit: contain;
border-radius: var(--size-rounded-icon);
}
.title {
margin: 0.25rem 0;
color: var(--color-text-dark);
font-size: var(--font-size-xl);
}
.side-descriptor {
display: flex;
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;
}
}
.buttons {
display: flex;
flex-direction: row;
button,
a {
display: flex;
}
}
.description {
margin-top: var(--spacing-card-sm);
margin-bottom: 0.5rem;
color: var(--color-text-dark);
font-size: var(--font-size-nm);
}
.categories {
margin: 0.25rem 0;
color: var(--color-text-secondary);
font-size: var(--font-size-nm);
}
.stats {
.stat {
font-size: var(--font-size-lg);
font-weight: bold;
}
.label {
margin-right: 0.125rem;
}
}
.dates {
margin: 0.75rem 0;
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-nm);
display: flex;
align-items: center;
margin-bottom: 0.25rem;
.label {
margin-right: 0.25rem;
}
svg {
height: 1rem;
margin-right: 0.25rem;
}
}
}
}
.project-info {
height: auto;
overflow: hidden;
@media screen and (min-width: 1024px) {
min-width: 21rem;
max-width: 21rem;
margin-right: var(--spacing-card-md);
}
h3 {
font-weight: bold;
color: var(--color-heading);
margin-bottom: 0.3rem;
}
.featured-version {
display: flex;
flex-direction: row;
margin-top: var(--spacing-card-md);
.download {
display: flex;
align-items: center;
height: 2.5rem;
width: 2.5rem;
border-radius: 1.5rem;
color: var(--color-brand-inverted);
background-color: var(--color-brand);
margin-right: var(--spacing-card-sm);
&:hover {
background-color: var(--color-brand-hover);
}
svg {
width: 1.5rem;
margin: auto;
}
flex-shrink: 0;
}
.info {
display: flex;
flex-direction: column;
.top {
font-weight: bold;
}
}
}
.links {
a {
display: inline-flex;
align-items: center;
border-radius: 1rem;
svg,
img {
height: 1rem;
width: 1rem;
}
span {
margin-left: 0.25rem;
text-decoration: underline;
line-height: 2rem;
}
&:hover {
svg,
img,
span {
color: var(--color-link);
}
}
&:not(:last-child)::after {
content: '•';
margin: 0 0.5rem;
}
}
}
.team-member {
align-items: center;
margin-bottom: 0.25rem;
img {
border-radius: var(--size-rounded-icon);
height: 50px;
width: 50px;
}
.member-info {
overflow: hidden;
margin: auto 0 auto 0.5rem;
.name {
font-weight: bold;
}
p {
font-size: var(--font-size-sm);
margin: 0.2rem 0;
}
}
}
.infos {
.info {
display: flex;
margin: 0.5rem 0;
.key {
font-weight: bold;
color: var(--color-text-secondary);
width: 40%;
}
.value {
width: 50%;
text-transform: capitalize;
}
.uppercase {
text-transform: uppercase;
}
}
}
}
@media screen and (max-width: 550px) {
.title a {
display: none;
}
}
@media screen and (max-width: 800px) {
.project-navigation {
display: block;
overflow-x: auto;
overflow-wrap: break-word;
overflow-y: hidden;
}
}
@media screen and (min-width: 1024px) {
.content {
max-width: calc(1280px - 21rem);
}
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<div class="content card">
<div v-for="version in versions" :key="version.id">
<div class="version-header">
<span :class="'circle ' + version.version_type" />
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURIComponent(version.version_number)}`"
>{{ version.name }}</nuxt-link
>
</h2>
<span v-if="members.find((x) => x.user.id === version.author_id)">
by
<nuxt-link
class="text-link"
:to="
'/user/' +
members.find((x) => x.user.id === version.author_id).user
.username
"
>{{
members.find((x) => x.user.id === version.author_id).user
.username
}}</nuxt-link
>
</span>
<span>
on {{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
>
</div>
<a
:href="$parent.findPrimary(version).url"
class="iconified-button download"
@click.prevent="
$parent.downloadFile(
$parent.findPrimary(version).hashes.sha1,
$parent.findPrimary(version).url
)
"
>
<DownloadIcon />
Download
</a>
</div>
<div
v-highlightjs
:class="'markdown-body ' + version.version_type"
v-html="
version.changelog
? $xss($md.render(version.changelog))
: 'No changelog specified.'
"
/>
</div>
</div>
</template>
<script>
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
export default {
components: {
DownloadIcon,
},
auth: false,
props: {
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
},
}
</script>
<style lang="scss" scoped>
.content {
max-width: calc(100% - (2 * var(--spacing-card-lg)));
}
.version-header {
display: flex;
align-items: center;
.circle {
min-width: 0.75rem;
min-height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
&.alpha {
background-color: var(--color-badge-red-bg);
}
&.release {
background-color: var(--color-badge-green-bg);
}
&.beta {
background-color: var(--color-badge-yellow-bg);
}
}
.version-header-text {
display: flex;
align-items: baseline;
margin: 0 0.75rem;
flex-wrap: wrap;
h2 {
margin: 0;
font-size: var(--font-size-lg);
}
h2,
span {
padding-right: 0.25rem;
}
}
.download {
display: none;
@media screen and (min-width: 800px) {
display: flex;
}
}
}
.markdown-body {
margin: 0.5rem 0.5rem 1rem calc(0.375rem - 1px);
padding-left: 1.275rem;
border-left: 2px solid var(--color-text);
&.alpha {
border-left-color: var(--color-badge-red-bg);
}
&.release {
border-left-color: var(--color-badge-green-bg);
}
&.beta {
border-left-color: var(--color-badge-yellow-bg);
}
}
</style>

782
pages/_type/_id/edit.vue Normal file
View File

@@ -0,0 +1,782 @@
<template>
<div class="page-contents">
<header class="card columns">
<h3 class="column-grow-1">Edit project</h3>
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}`"
class="iconified-button column"
>
Back
</nuxt-link>
<button
v-if="
project.status === 'rejected' ||
project.status === 'draft' ||
project.status === 'unlisted'
"
title="Submit for approval"
class="iconified-button column"
:disabled="!$nuxt.$loading"
@click="saveProjectReview"
>
Submit for approval
</button>
<button
title="Save"
class="iconified-button brand-button-colors column"
:disabled="!$nuxt.$loading"
@click="saveProject"
>
<CheckIcon />
Save
</button>
</header>
<section class="card essentials">
<h3>Name</h3>
<label>
<span>
Be creative! Generic project names will be harder to search for.
</span>
<input
v-model="newProject.title"
type="text"
placeholder="Enter the name"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
<h3>Summary</h3>
<label>
<span>
Give a short description of your project that will appear on search
pages.
</span>
<input
v-model="newProject.description"
type="text"
placeholder="Enter the summary"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
<h3>Categories</h3>
<label>
<span>
Select up to 3 categories that will help others find your project.
</span>
<Multiselect
id="categories"
v-model="newProject.categories"
:options="
$tag.categories
.filter((x) => x.project_type === project.project_type)
.map((it) => it.name)
"
:loading="$tag.categories.length === 0"
:multiple="true"
:searchable="false"
:show-no-results="false"
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:max="3"
:limit="6"
:hide-selected="true"
placeholder="Choose categories"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
<h3>Vanity URL (slug)</h3>
<label>
<span>
Set this to something that will looks nice in your project's URL.
</span>
<input
id="name"
v-model="newProject.slug"
type="text"
placeholder="Enter the vanity URL slug"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
</section>
<section class="card project-icon rows">
<h3>Icon</h3>
<img
:src="
previewImage
? previewImage
: newProject.icon_url && !iconChanged
? newProject.icon_url
: 'https://cdn.modrinth.com/placeholder.svg'
"
alt="preview-image"
/>
<file-input
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image"
prompt="Choose image or drag it here"
@change="showPreviewImage"
:disabled="(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS"
/>
<button
class="iconified-button"
@click="
icon = null
previewImage = null
iconChanged = true
"
:disabled="(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS"
>
<TrashIcon />
Reset icon
</button>
</section>
<section class="card game-sides">
<h3>Supported environments</h3>
<div class="columns">
<span> Let others know what environments your project supports. </span>
<div class="labeled-control">
<h3>Client</h3>
<Multiselect
v-model="clientSideType"
placeholder="Select one"
:options="sideTypes"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</div>
<div class="labeled-control">
<h3>Server</h3>
<Multiselect
v-model="serverSideType"
placeholder="Select one"
:options="sideTypes"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</div>
</div>
</section>
<section class="card description">
<h3>
<label
for="body"
title="You can type an extended description of your project here."
>
Description
</label>
</h3>
<span>
You can type an extended description of your mod here. This editor
supports Markdown. Its syntax can be found
<a
href="https://guides.github.com/features/mastering-markdown/"
target="_blank"
rel="noopener noreferrer"
class="text-link"
>here</a
>.
</span>
<ThisOrThat
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
/>
<div class="edit-wrapper">
<div v-if="bodyViewMode === 'source'" class="textarea-wrapper">
<textarea
id="body"
v-model="newProject.body"
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
/>
</div>
<div
v-if="bodyViewMode === 'preview'"
v-highlightjs
class="markdown-body"
v-html="
newProject.body
? $xss($md.render(newProject.body))
: 'No body specified.'
"
></div>
</div>
</section>
<section class="card extra-links">
<div class="title">
<h3>External links</h3>
</div>
<label
title="A place for users to report bugs, issues, and concerns about your project."
>
<span>Issue tracker</span>
<input
v-model="newProject.issues_url"
type="url"
placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
<label
title="A page/repository containing the source code for your project"
>
<span>Source code</span>
<input
v-model="newProject.source_url"
type="url"
placeholder="Enter a valid URL"
/>
</label>
<label
title="A page containing information, documentation, and help for the project."
>
<span>Wiki page</span>
<input
v-model="newProject.wiki_url"
type="url"
placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
<label title="An invitation link to your Discord server.">
<span>Discord invite</span>
<input
v-model="newProject.discord_url"
type="url"
placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
</section>
<section class="card license">
<div class="title">
<h3>License</h3>
</div>
<label>
<span>
It is very important to choose a proper license for your mod. You may
choose one from our list or provide a URL to a custom license.
<br />
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>
<div class="input-group">
<Multiselect
v-model="license"
placeholder="Select one"
track-by="short"
label="name"
:options="$tag.licenses"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
<input
v-model="license_url"
type="url"
placeholder="License URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</div>
</label>
</section>
<section class="card donations">
<div class="title">
<h3>Donation links</h3>
<button
title="Add a link"
class="iconified-button"
:disabled="false"
@click="
donationPlatforms.push({})
donationLinks.push('')
"
>
<PlusIcon />
Add a link
</button>
</div>
<div v-for="(item, index) in donationPlatforms" :key="index">
<label title="The donation link.">
<span>Donation Link</span>
<input
v-model="donationLinks[index]"
type="url"
placeholder="Enter a valid URL"
/>
</label>
<label title="The donation platform of the link.">
<span>Donation Platform</span>
<Multiselect
v-model="donationPlatforms[index]"
placeholder="Select one"
track-by="short"
label="name"
:options="$tag.donationPlatforms"
:searchable="false"
:close-on-select="true"
:show-labels="false"
/>
</label>
<button
class="iconified-button"
@click="
donationPlatforms.splice(index, 1)
donationLinks.splice(index, 1)
"
>
<TrashIcon />
Remove Link
</button>
<hr
v-if="
donationPlatforms.length > 0 &&
index !== donationPlatforms.length - 1
"
/>
</div>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import FileInput from '~/components/ui/FileInput'
import ThisOrThat from '~/components/ui/ThisOrThat'
export default {
components: {
FileInput,
ThisOrThat,
Multiselect,
TrashIcon,
CheckIcon,
PlusIcon,
},
beforeRouteLeave(to, from, next) {
if (
this.isEditing &&
!window.confirm('Are you sure that you want to leave without saving?')
) {
return
}
next()
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
},
data() {
return {
newProject: {},
clientSideType: '',
serverSideType: '',
license: { short: '', name: '' },
license_url: '',
donationPlatforms: [],
donationLinks: [],
isProcessing: false,
previewImage: null,
compiledBody: '',
icon: null,
iconChanged: false,
sideTypes: ['Required', 'Optional', 'Unsupported'],
isEditing: true,
bodyViewMode: 'source',
}
},
fetch() {
this.newProject = this.project
this.newProject.license.short = this.newProject.license.id
if (this.newProject.donation_urls) {
for (const platform of this.newProject.donation_urls) {
this.donationPlatforms.push({
short: platform.id,
name: platform.platform,
})
this.donationLinks.push(platform.url)
}
}
this.license = {
short: this.newProject.license.id,
name: this.newProject.license.name,
}
this.license_url = this.newProject.license.url
this.clientSideType =
this.newProject.client_side.charAt(0) +
this.newProject.client_side.slice(1)
this.serverSideType =
this.newProject.server_side.charAt(0) +
this.newProject.server_side.slice(1)
},
watch: {
license(newValue, oldValue) {
if (newValue == null) {
this.license_url = ''
return
}
switch (newValue.short) {
case 'custom':
this.license_url = ''
break
default:
this.license_url = `https://cdn.modrinth.com/licenses/${newValue.short}.txt`
}
},
},
mounted() {
function preventLeave(e) {
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', preventLeave)
this.$once('hook:beforeDestroy', () => {
window.removeEventListener('beforeunload', preventLeave)
})
},
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_MOD = 1 << 7
},
methods: {
async saveProjectReview() {
this.isProcessing = true
await this.saveProject()
},
async saveProject() {
this.$nuxt.$loading.start()
try {
const data = {
title: this.newProject.title,
description: this.newProject.description,
body: this.newProject.body,
categories: this.newProject.categories,
issues_url: this.newProject.issues_url,
source_url: this.newProject.source_url,
wiki_url: this.newProject.wiki_url,
license_url: this.license_url,
discord_url: this.newProject.discord_url,
license_id: this.license.short,
client_side: this.clientSideType.toLowerCase(),
server_side: this.serverSideType.toLowerCase(),
slug: this.newProject.slug,
license: this.license.short,
donation_urls: this.donationPlatforms.map((it, index) => {
return {
id: it.short,
platform: it.name,
url: this.donationLinks[index],
}
}),
}
if (this.isProcessing) {
data.status = 'processing'
}
await this.$axios.patch(
`project/${this.newProject.id}`,
data,
this.$auth.headers
)
if (this.iconChanged) {
await this.$axios.patch(
`project/${this.newProject.id}/icon?ext=${
this.icon.type.split('/')[this.icon.type.split('/').length - 1]
}`,
this.icon,
this.$auth.headers
)
}
this.$emit('update:featuredVersions', this.newProject)
this.isEditing = false
await this.$router.replace(
`/${this.project.project_type}/${
this.newProject.slug ? this.newProject.slug : this.newProject.id
}`
)
} 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()
},
showPreviewImage(files) {
const reader = new FileReader()
this.iconChanged = true
this.icon = files[0]
reader.readAsDataURL(this.icon)
reader.onload = (event) => {
this.previewImage = event.target.result
}
},
},
}
</script>
<style lang="scss" scoped>
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
h3 {
margin-top: 0;
}
}
label {
display: flex;
span {
flex: 2;
padding-right: var(--spacing-card-lg);
}
input,
.multiselect,
.input-group {
flex: 3;
height: fit-content;
}
}
.input-group {
display: flex;
flex-direction: column;
* {
margin-bottom: var(--spacing-card-sm);
}
}
.textarea-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
textarea {
flex: 1;
overflow-y: auto;
resize: none;
max-width: 100%;
}
}
.page-contents {
display: grid;
grid-template:
'header header header' auto
'essentials essentials project-icon' auto
'game-sides game-sides game-sides' auto
'description description description' auto
'extra-links extra-links extra-links' auto
'license license license' auto
'donations donations donations' auto
'footer footer footer' auto
/ 4fr 1fr 2fr;
column-gap: var(--spacing-card-md);
row-gap: var(--spacing-card-md);
}
header {
grid-area: header;
padding: var(--spacing-card-md) var(--spacing-card-lg);
h3 {
margin: auto 0;
color: var(--color-text-dark);
font-weight: var(--font-weight-extrabold);
}
button {
margin-left: 0.5rem;
}
}
section.essentials {
grid-area: essentials;
}
section.project-icon {
grid-area: project-icon;
img {
max-width: 100%;
margin-bottom: 1rem;
border-radius: var(--size-rounded-lg);
}
.iconified-button {
width: 9rem;
margin-top: 0.5rem;
}
}
section.game-sides {
grid-area: game-sides;
.columns {
flex-wrap: wrap;
span {
flex: 2;
}
.labeled-control {
flex: 2;
margin-left: var(--spacing-card-lg);
}
}
}
section.description {
grid-area: description;
.separator {
margin: var(--spacing-card-sm) 0;
}
.edit-wrapper * {
min-height: 10rem;
max-height: 40rem;
}
.markdown-body {
overflow-y: auto;
padding: 0 var(--spacing-card-sm);
}
}
section.extra-links {
grid-area: extra-links;
label {
align-items: center;
margin-top: var(--spacing-card-sm);
span {
flex: 1;
}
}
}
section.license {
grid-area: license;
label {
margin-top: var(--spacing-card-sm);
}
}
section.donations {
grid-area: donations;
label {
align-items: center;
margin-top: var(--spacing-card-sm);
span {
flex: 1;
}
}
}
.footer {
grid-area: footer;
}
.choose-image {
cursor: pointer;
}
.card {
margin-bottom: 0;
}
</style>

416
pages/_type/_id/gallery.vue Normal file
View File

@@ -0,0 +1,416 @@
<template>
<div>
<div class="buttons">
<button
v-if="currentMember"
class="iconified-button"
@click="
newGalleryItems.push({
title: '',
description: '',
featured: false,
url: '',
})
"
>
<UploadIcon />
Upload
</button>
<button
v-if="
newGalleryItems.length > 0 ||
editGalleryIndexes.length > 0 ||
deleteGalleryUrls.length > 0
"
class="action brand-button-colors iconified-button"
@click="saveGallery"
>
<CheckIcon />
Save
</button>
<button
v-if="
newGalleryItems.length > 0 ||
editGalleryIndexes.length > 0 ||
deleteGalleryUrls.length > 0
"
class="action iconified-button"
@click="resetGallery"
>
<TrashIcon />
Discard Changes
</button>
</div>
<div class="items">
<div
v-for="(item, index) in gallery"
:key="index"
class="card gallery-item"
>
<img
:src="
item.url
? item.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="item.title ? item.title : 'gallery-image'"
/>
<div class="gallery-body">
<div v-if="editGalleryIndexes.includes(index)" class="gallery-info">
<input
v-model="item.title"
type="text"
placeholder="Enter the title..."
/>
<div class="textarea-wrapper">
<textarea
id="body"
v-model="item.description"
placeholder="Enter the description..."
/>
</div>
<Checkbox v-model="item.featured" label="Featured" />
</div>
<div v-else class="gallery-info">
<h2 v-if="item.title">{{ item.title }}</h2>
<p v-if="item.description">{{ item.description }}</p>
</div>
</div>
<div class="gallery-bottom">
<div class="gallery-created">
<CalendarIcon />
{{ $dayjs(item.created).format('MMMM D, YYYY') }}
</div>
<div v-if="currentMember" class="gallery-buttons">
<button
v-if="editGalleryIndexes.includes(index)"
class="iconified-button"
@click="
editGalleryIndexes.splice(editGalleryIndexes.indexOf(index), 1)
gallery[index] = JSON.parse(
JSON.stringify(project.gallery[index])
)
"
>
<CrossIcon />
Cancel
</button>
<button
v-else
class="iconified-button"
@click="editGalleryIndexes.push(index)"
>
<EditIcon />
Edit
</button>
<button
class="iconified-button"
@click="
deleteGalleryUrls.push(item.url)
gallery.splice(index, 1)
"
>
<TrashIcon />
Delete
</button>
</div>
</div>
</div>
<div
v-for="(item, index) in newGalleryItems"
:key="index + 'new'"
class="card gallery-item"
>
<img
:src="
newGalleryItems[index].preview
? newGalleryItems[index].preview
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="item.title ? item.title : 'gallery-image'"
/>
<div class="gallery-body">
<div class="gallery-info">
<input
v-model="item.title"
type="text"
placeholder="Enter the title..."
/>
<div class="textarea-wrapper">
<textarea
id="body"
v-model="item.description"
placeholder="Enter the description..."
/>
</div>
<Checkbox v-model="item.featured" label="Featured" />
</div>
</div>
<div class="gallery-bottom">
<div class="gallery-buttons">
<SmartFileInput
accept="image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp"
prompt="Upload"
@change="(files) => showPreviewImage(files, index)"
/>
<button
class="iconified-button"
@click="newGalleryItems.splice(index, 1)"
>
<TrashIcon />
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import SmartFileInput from '~/components/ui/SmartFileInput'
import Checkbox from '~/components/ui/Checkbox'
export default {
components: {
CalendarIcon,
UploadIcon,
Checkbox,
EditIcon,
TrashIcon,
CheckIcon,
SmartFileInput,
CrossIcon,
},
auth: false,
beforeRouteLeave(to, from, next) {
this.resetGallery()
next()
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
},
data() {
return {
gallery: [],
newGalleryItems: [],
editGalleryIndexes: [],
deleteGalleryUrls: [],
}
},
fetch() {
this.gallery = JSON.parse(JSON.stringify(this.project.gallery))
},
methods: {
showPreviewImage(files, index) {
const reader = new FileReader()
this.newGalleryItems[index].icon = files[0]
if (this.newGalleryItems[index].icon instanceof Blob) {
reader.readAsDataURL(this.newGalleryItems[index].icon)
reader.onload = (event) => {
this.newGalleryItems[index].preview = event.target.result
// TODO: Find an alternative for this!
this.$forceUpdate()
}
}
},
async saveGallery() {
this.$nuxt.$loading.start()
try {
for (const item of this.newGalleryItems) {
let url = `project/${this.project.id}/gallery?ext=${
item.icon
? item.icon.type.split('/')[item.icon.type.split('/').length - 1]
: null
}&featured=${item.featured}`
if (item.title) url += `&title=${item.title}`
if (item.description) url += `&description=${item.description}`
await this.$axios.post(url, item.icon, this.$auth.headers)
}
for (const index of this.editGalleryIndexes) {
const item = this.gallery[index]
let url = `project/${
this.project.id
}/gallery?url=${encodeURIComponent(item.url)}&featured=${
item.featured
}`
if (item.title) url += `&title=${item.title}`
if (item.description) url += `&description=${item.description}`
await this.$axios.patch(url, {}, this.$auth.headers)
}
for (const url of this.deleteGalleryUrls) {
await this.$axios.delete(
`project/${this.project.id}/gallery?url=${encodeURIComponent(url)}`,
this.$auth.headers
)
}
const project = (
await this.$axios.get(
`project/${this.project.id}`,
this.$auth.headers
)
).data
this.$emit('update:project', project)
this.gallery = JSON.parse(JSON.stringify(project.gallery))
this.deleteGalleryUrls = []
this.editGalleryIndexes = []
this.newGalleryItems = []
} catch (err) {
const description = err.response.data.description
this.$notify({
group: 'main',
title: 'An error occurred',
text: description,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
this.$nuxt.$loading.finish()
},
resetGallery() {
this.newGalleryItems = []
this.editGalleryIndexes = []
this.deleteGalleryUrls = []
this.gallery = JSON.parse(JSON.stringify(this.project.gallery))
},
},
}
</script>
<style lang="scss" scoped>
.buttons {
display: flex;
button {
background-color: var(--color-raised-bg);
margin-right: 0.5rem;
margin-bottom: var(--spacing-card-md);
&:hover {
background-color: var(--color-button-bg);
}
&.brand-button-colors {
background-color: var(--color-brand);
&:hover {
background-color: var(--color-brand-hover);
}
}
}
}
.items {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
grid-gap: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr 1fr;
}
}
.gallery-item {
display: flex;
flex-direction: column;
img {
width: 100%;
margin-top: 0.5rem;
margin-bottom: 0;
min-height: 10rem;
object-fit: cover;
border-radius: var(--size-rounded-card);
}
.gallery-body {
flex-grow: 1;
width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md);
textarea {
border-radius: var(--size-rounded-sm);
}
.textarea-wrapper {
width: 14rem;
}
input {
width: calc(14rem - 2rem - 4px);
margin: 0 0 0.25rem;
}
.gallery-info {
h2 {
margin-bottom: 0.5rem;
}
p {
margin: 0 0 0.5rem 0;
}
}
}
.gallery-bottom {
width: calc(100% - 2 * var(--spacing-card-md));
padding: 0 var(--spacing-card-md) var(--spacing-card-sm)
var(--spacing-card-md);
.gallery-created {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
color: var(--color-icon);
svg {
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
}
}
.gallery-buttons {
display: flex;
}
}
}
</style>

27
pages/_type/_id/index.vue Normal file
View File

@@ -0,0 +1,27 @@
<template>
<div
v-highlightjs
class="markdown-body card"
v-html="$xss($md.render(project.body))"
></div>
</template>
<script>
export default {
auth: false,
props: {
project: {
type: Object,
default() {
return {}
},
},
},
}
</script>
<style lang="scss" scoped>
.markdown-body {
max-width: calc(100% - (2 * var(--spacing-card-lg)));
}
</style>

View File

@@ -0,0 +1,634 @@
<template>
<div>
<ConfirmPopup
ref="delete_popup"
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 project"
@proceed="deleteProject"
/>
<div class="card">
<h3>General</h3>
</div>
<section class="card">
<h3>Edit project</h3>
<label>
<span> This leads you to a page where you can edit your project. </span>
<nuxt-link class="iconified-button" to="edit">Edit</nuxt-link>
</label>
<h3>Create version</h3>
<label>
<span>
This leads to a page where you can create a version for your project.
</span>
<nuxt-link
class="iconified-button"
to="version/create"
:disabled="
(currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
"
>Create version</nuxt-link
>
</label>
<h3>Delete project</h3>
<label>
<span>
Removes your project from Modrinth's servers and search. Clicking on
this will delete your project, so be extra careful!
</span>
<div
class="iconified-button"
:disabled="
(currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT
"
@click="showPopup"
>
Delete project
</div>
</label>
</section>
<div class="card columns team-invite">
<h3>Team members</h3>
<div
v-if="(currentMember.permissions & MANAGE_INVITES) === MANAGE_INVITES"
class="column"
>
<input
id="username"
v-model="currentUsername"
type="text"
placeholder="Username"
/>
<label for="username" class="hidden">Username</label>
<button
class="iconified-button brand-button-colors column"
@click="inviteTeamMember"
>
<PlusIcon />
Invite
</button>
</div>
</div>
<div
v-for="(member, index) in allTeamMembers"
:key="member.user.id"
class="card member"
:class="{ open: openTeamMembers.includes(member.user.id) }"
>
<div class="member-header">
<div class="info">
<img :src="member.avatar_url" :alt="member.name" />
<div class="text">
<h4>{{ member.name }}</h4>
<h3>{{ member.role }}</h3>
</div>
</div>
<div class="side-buttons">
<Badge v-if="member.accepted" type="accepted" color="green" />
<Badge v-else type="pending" color="yellow" />
<button
class="dropdown-icon"
@click="
openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id)
: (openTeamMembers = openTeamMembers.filter(
(it) => it !== member.user.id
))
"
>
<DropdownIcon />
</button>
</div>
</div>
<div class="content">
<div class="main-info">
<label>
Role:
<input
v-model="allTeamMembers[index].role"
type="text"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
/>
</label>
</div>
<h3>Permissions</h3>
<div class="permissions">
<Checkbox
:value="
(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
"
label="Upload version"
@input="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
/>
<Checkbox
:value="
(member.permissions & DELETE_VERSION) === DELETE_VERSION ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_VERSION) !== DELETE_VERSION
"
label="Delete version"
@input="allTeamMembers[index].permissions ^= DELETE_VERSION"
/>
<Checkbox
:value="
(member.permissions & EDIT_DETAILS) === EDIT_DETAILS ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
label="Edit details"
@input="allTeamMembers[index].permissions ^= EDIT_DETAILS"
/>
<Checkbox
:value="
(member.permissions & EDIT_BODY) === EDIT_BODY ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_BODY) !== EDIT_BODY
"
label="Edit body"
@input="allTeamMembers[index].permissions ^= EDIT_BODY"
/>
<Checkbox
:value="
(member.permissions & MANAGE_INVITES) === MANAGE_INVITES ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & MANAGE_INVITES) !== MANAGE_INVITES
"
label="Manage invites"
@input="allTeamMembers[index].permissions ^= MANAGE_INVITES"
/>
<Checkbox
:value="
(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
"
label="Remove member"
@input="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
/>
<Checkbox
:value="
(member.permissions & EDIT_MEMBER) === EDIT_MEMBER ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
label="Edit member"
@input="allTeamMembers[index].permissions ^= EDIT_MEMBER"
/>
<Checkbox
:value="
(member.permissions & DELETE_PROJECT) === DELETE_PROJECT ||
member.role === 'Owner'
"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT
"
label="Delete project"
@input="allTeamMembers[index].permissions ^= DELETE_PROJECT"
/>
</div>
<div class="actions">
<button
class="iconified-button"
:disabled="
member.role === 'Owner' ||
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
@click="removeTeamMember(index)"
>
<TrashIcon />
Remove member
</button>
<button
v-if="
member.role !== 'Owner' &&
currentMember.role === 'Owner' &&
member.accepted
"
class="iconified-button"
@click="transferOwnership(index)"
>
<UserIcon />
Transfer ownership
</button>
<button
class="iconified-button brand-button-colors"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
@click="updateTeamMember(index)"
>
<CheckIcon />
Save changes
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import ConfirmPopup from '~/components/ui/ConfirmPopup'
import Checkbox from '~/components/ui/Checkbox'
import Badge from '~/components/ui/Badge'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
export default {
components: {
DropdownIcon,
ConfirmPopup,
Checkbox,
Badge,
PlusIcon,
CheckIcon,
TrashIcon,
UserIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
allMembers: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
},
data() {
return {
currentUsername: '',
openTeamMembers: [],
allTeamMembers: [],
}
},
fetch() {
this.allTeamMembers = this.allMembers
},
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
},
methods: {
async inviteTeamMember() {
this.$nuxt.$loading.start()
try {
const user = (await this.$axios.get(`user/${this.currentUsername}`))
.data
const data = {
user_id: user.id,
}
await this.$axios.post(
`team/${this.project.team}/members`,
data,
this.$auth.headers
)
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
async removeTeamMember(index) {
this.$nuxt.$loading.start()
try {
await this.$axios.delete(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
this.$auth.headers
)
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
async updateTeamMember(index) {
this.$nuxt.$loading.start()
try {
const data = {
permissions: this.allTeamMembers[index].permissions,
role: this.allTeamMembers[index].role,
}
await this.$axios.patch(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
data,
this.$auth.headers
)
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
async transferOwnership(index) {
this.$nuxt.$loading.start()
try {
await this.$axios.patch(
`team/${this.project.team}/owner`,
{
user_id: this.allTeamMembers[index].user.id,
},
this.$auth.headers
)
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
showPopup() {
if (
(this.currentMember.permissions & this.DELETE_MOD) ===
this.DELETE_MOD
) {
this.$refs.delete_popup.show()
}
},
async deleteProject() {
await this.$axios.delete(`project/${this.project.id}`, this.$auth.headers)
await this.$store.dispatch('user/fetchProjects')
await this.$router.push(`/user/${this.$auth.user.username}`)
this.$notify({
group: 'main',
title: 'Action Success',
text: 'Your _type has been successfully deleted.',
type: 'success',
})
},
async updateMembers() {
this.allTeamMembers = (
await this.$axios.get(
`team/${this.project.team}/members`,
this.$auth.headers
)
).data.map((it) => ({
avatar_url: it.user.avatar_url,
name: it.user.username,
...it,
}))
},
},
}
</script>
<style lang="scss" scoped>
.member {
margin-bottom: var(--spacing-card-md);
.member-header {
display: flex;
justify-content: space-between;
.info {
display: flex;
img {
border-radius: var(--size-rounded-icon);
height: 50px;
width: 50px;
}
.text {
margin: auto 0 auto 0.5rem;
h4 {
font-weight: normal;
margin: 0;
}
h3 {
text-transform: uppercase;
margin-top: 0.1rem;
margin-bottom: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-extrabold);
letter-spacing: 0.02rem;
}
}
}
.side-buttons {
display: flex;
align-items: center;
.dropdown-icon {
margin-left: 1rem;
cursor: pointer;
color: var(--color-text-dark);
background-color: unset;
transition: 150ms ease transform;
padding: unset;
}
}
}
.content {
display: none;
.main-info {
margin-bottom: var(--spacing-card-lg);
@media screen and (min-width: 1024px) {
label {
align-items: center;
input {
margin-left: 1rem;
}
}
}
}
.permissions {
margin: 1rem 0;
max-width: 45rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0.5rem;
label {
flex-direction: row;
input {
flex: none;
margin-right: 0.5rem;
}
}
}
}
&.open {
.member-header {
.dropdown-icon {
transform: rotate(180deg);
}
}
.content {
display: unset;
margin: var(--spacing-card-lg);
}
}
}
input,
button {
&:disabled {
cursor: not-allowed !important;
}
}
section {
margin-bottom: var(--spacing-card-md);
label {
display: flex;
span {
flex: 2;
padding-right: var(--spacing-card-lg);
}
input {
flex: 3;
height: fit-content;
}
div,
a {
display: block;
text-align: center;
height: fit-content;
flex: 1;
@media screen and (max-width: 1024px) {
margin: 0.5rem 0 1rem 0;
}
}
div:hover {
cursor: pointer;
}
}
}
.team-invite {
@media screen and (max-width: 1024px) {
flex-direction: column;
h3 {
margin-bottom: 0.5rem;
}
}
h3 {
margin-right: auto;
}
> div {
display: flex;
align-items: center;
input {
margin-right: 1rem;
}
@media screen and (max-width: 500px) {
display: flex;
flex-direction: column;
input {
margin: 0;
}
button {
margin-top: 0.5rem;
}
}
}
}
.actions {
display: flex;
button {
margin-right: 0.5rem;
&:first-child {
margin-left: auto;
}
}
}
</style>

1020
pages/_type/_id/version.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
<template>
<div></div>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,30 @@
<template>
<div></div>
</template>
<script>
export default {
auth: false,
head: {
title: 'Mods - Modrinth',
meta: [
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: 'Mods',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Mods',
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/mods`,
},
],
},
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,8 @@
<template>
<div></div>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,241 @@
<template>
<div class="content card">
<nuxt-link
v-if="currentMember"
to="version/create"
class="iconified-button new-version"
>
<UploadIcon />
Upload
</nuxt-link>
<table>
<thead>
<tr>
<th></th>
<th>Version</th>
<th>Supports</th>
<th>Stats</th>
</tr>
</thead>
<tbody>
<tr v-for="version in versions" :key="version.id">
<td>
<a
:href="$parent.findPrimary(version).url"
class="download-button"
@click.prevent="
$parent.downloadFile(
$parent.findPrimary(version).hashes.sha1,
$parent.findPrimary(version).url
)
"
>
<DownloadIcon />
</a>
</td>
<td>
<div class="info">
<div class="top">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURIComponent(version.version_number)}`"
>
{{ version.name }}
</nuxt-link>
</div>
<div class="bottom">
<VersionBadge
v-if="version.version_type === 'release'"
type="release"
color="green"
/>
<VersionBadge
v-else-if="version.version_type === 'beta'"
type="beta"
color="yellow"
/>
<VersionBadge
v-else-if="version.version_type === 'alpha'"
type="alpha"
color="red"
/>
<span class="divider" />
<span class="version_number">{{ version.version_number }}</span>
</div>
<div class="mobile-info">
<p>
{{
version.loaders
.map((x) => x.charAt(0).toUpperCase() + x.slice(1))
.join(', ') +
' ' +
version.game_versions[version.game_versions.length - 1]
}}
</p>
<p></p>
<p>
<strong>{{ $parent.formatNumber(version.downloads) }}</strong>
downloads
</p>
<p>
Published on
<strong>{{
$dayjs(version.date_published).format('MMM D, YYYY')
}}</strong>
</p>
</div>
</div>
</td>
<td>
<p>
{{
version.loaders
.map((x) => x.charAt(0).toUpperCase() + x.slice(1))
.join(', ')
}}
</p>
<p>{{ version.game_versions[version.game_versions.length - 1] }}</p>
</td>
<td>
<p>
<span>{{ $parent.formatNumber(version.downloads) }}</span>
downloads
</p>
<p>
Published on
<span>{{
$dayjs(version.date_published).format('MMM D, YYYY')
}}</span>
</p>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
import VersionBadge from '~/components/ui/Badge'
export default {
components: {
UploadIcon,
DownloadIcon,
VersionBadge,
},
auth: false,
props: {
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
},
}
</script>
<style lang="scss" scoped>
.content {
max-width: calc(100% - (2 * var(--spacing-card-lg)));
}
.new-version {
max-width: 5.25rem;
}
table {
border-collapse: separate;
border-spacing: 0 0.75rem;
th {
text-align: left;
font-size: var(--font-size-md);
&:nth-child(3),
&:nth-child(4) {
display: none;
}
}
tr {
td:nth-child(2) {
padding-right: 2rem;
min-width: 13.875rem;
.top {
font-weight: bold;
}
.bottom {
display: flex;
flex-direction: row;
align-items: center;
text-overflow: ellipsis;
margin-top: 0.25rem;
.divider {
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
display: inline-block;
margin: 0 0.25rem;
background-color: var(--color-text);
}
}
.mobile-info {
p {
margin: 0.25rem 0 0;
}
}
}
td:nth-child(3) {
display: none;
width: 100%;
p {
margin: 0.25rem 0;
}
}
td:nth-child(4) {
display: none;
min-width: 15rem;
p {
margin: 0.25rem 0;
span {
font-weight: bold;
}
}
}
}
}
@media screen and (min-width: 1024px) {
table {
tr {
th:nth-child(3),
td:nth-child(3),
th:nth-child(4),
td:nth-child(4) {
display: table-cell;
}
}
}
.mobile-info {
display: none;
}
}
</style>