Update master with new auth (#1236)

* Begin UI for threads and moderation overhaul

* Hide close button on non-report threads

* Fix review age coloring

* Add project count

* Remove action buttons from queue page and add queued date to project page

* Hook up to actual data

* Remove unused icon

* Get up to 1000 projects in queue

* prettier

* more prettier

* Changed all the things

* lint

* rebuild

* Add omorphia

* Workaround formatjs bug in ThreadSummary.vue

* Fix notifications page on prod

* Fix a few notifications and threads bugs

* lockfile

* Fix duplicate button styles

* more fixes and polishing

* More fixes

* Remove legacy pages

* More bugfixes

* Add some error catching for reports and notifications

* More error handling

* fix lint

* Add inbox links

* Remove loading component and rename member header

* Rely on threads always existing

* Handle if project update notifs are not grouped

* oops

* Fix chips on notifications page

* Import ModalModeration

* finish threads

* New authentication (#1234)

* Initial new auth work

* more auth pages

* Finish most

* more

* fix on landing page

* Finish everything but PATs + Sessions

* fix threads merge bugs

* fix cf pages ssr

* fix most issues

* Finish authentication

* Fix merge

---------

Co-authored-by: triphora <emma@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-07-20 11:19:42 -07:00 committed by GitHub
parent a5613ebb10
commit 34d63f3557
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 2373 additions and 711 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"><defs><style>.cls-1{fill:#fff;}</style></defs><g id="图层_2" data-name="图层 2"><g id="Discord_Logos" data-name="Discord Logos"><g id="Discord_Logo_-_Large_-_White" data-name="Discord Logo - Large - White"><path class="cls-1" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 985 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 380 380" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" fill="currentColor">
<g transform="matrix(1.97904,0,0,1.97904,-186.013,-186.006)">
<g id="LOGO">
<path d="M282.83,170.73L282.56,170.04L256.42,101.82C255.888,100.483 254.946,99.349 253.73,98.58C251.243,97.036 248.038,97.208 245.73,99.01C244.613,99.917 243.803,101.146 243.41,102.53L225.76,156.53L154.29,156.53L136.64,102.53C136.257,101.139 135.445,99.903 134.32,99C132.012,97.198 128.807,97.026 126.32,98.57C125.106,99.342 124.165,100.475 123.63,101.81L97.44,170L97.18,170.69C89.472,190.829 96.065,213.803 113.28,226.79L113.37,226.86L113.61,227.03L153.43,256.85L173.13,271.76L185.13,280.82C188.006,283.004 192.014,283.004 194.89,280.82L206.89,271.76L226.59,256.85L266.65,226.85L266.75,226.77C283.925,213.782 290.505,190.849 282.83,170.73Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<circle cx="50" cy="50" r="50" style="fill:white;"/>
<g id="Google__G__Logo.svg" transform="matrix(0.0991612,0,0,0.0991612,49.3739,50)">
<g transform="matrix(1,0,0,1,-352.8,-360)">
<clipPath id="_clip1">
<rect x="0" y="0" width="705.6" height="720"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(1,0,0,1,4477.16,2891.98)">
<path d="M-4117.16,-2597.44L-4117.16,-2458.02L-3923.42,-2458.02C-3931.93,-2413.18 -3957.46,-2375.22 -3995.75,-2349.69L-3878.91,-2259.03C-3810.84,-2321.87 -3771.56,-2414.16 -3771.56,-2523.8C-3771.56,-2549.33 -3773.85,-2573.87 -3778.11,-2597.43L-4117.16,-2597.44Z" style="fill:rgb(66,133,244);fill-rule:nonzero;"/>
<path d="M-4318.92,-2463.46L-4345.27,-2443.29L-4438.55,-2370.64C-4379.31,-2253.15 -4257.9,-2171.98 -4117.17,-2171.98C-4019.97,-2171.98 -3938.48,-2204.05 -3878.92,-2259.03L-3995.75,-2349.69C-4027.83,-2328.09 -4068.74,-2315 -4117.17,-2315C-4210.77,-2315 -4290.3,-2378.16 -4318.77,-2463.25L-4318.92,-2463.46Z" style="fill:rgb(52,168,83);fill-rule:nonzero;"/>
<path d="M-4438.55,-2693.33C-4463.09,-2644.89 -4477.16,-2590.24 -4477.16,-2531.99C-4477.16,-2473.73 -4463.09,-2419.08 -4438.55,-2370.64C-4438.55,-2370.32 -4318.76,-2463.59 -4318.76,-2463.59C-4325.96,-2485.19 -4330.22,-2508.09 -4330.22,-2531.99C-4330.22,-2555.88 -4325.96,-2578.79 -4318.76,-2600.39L-4438.55,-2693.33Z" style="fill:rgb(251,188,5);fill-rule:nonzero;"/>
<path d="M-4117.16,-2748.64C-4064.14,-2748.64 -4017.02,-2730.31 -3979.38,-2694.97L-3876.29,-2798.06C-3938.8,-2856.31 -4019.96,-2891.99 -4117.16,-2891.99C-4257.89,-2891.99 -4379.31,-2811.15 -4438.55,-2693.33L-4318.76,-2600.38C-4290.29,-2685.47 -4210.76,-2748.64 -4117.16,-2748.64Z" style="fill:rgb(234,67,53);fill-rule:nonzero;"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-steam" viewBox="0 0 16 16">
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006 4.304 1.769A2.198 2.198 0 0 1 5.62 8.88l1.96-2.844-.001-.04a3.046 3.046 0 0 1 3.042-3.043 3.046 3.046 0 0 1 3.042 3.043 3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11 2.217 2.217 0 0 1-1.312-1.568L.33 10.333Z"/>
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165 1.705 1.705 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029 2.03 2.03 0 0 0 2.027-2.029 2.03 2.03 0 0 0-2.027-2.027 2.03 2.03 0 0 0-2.027 2.027Zm2.03-1.527a1.524 1.524 0 1 1-.002 3.048 1.524 1.524 0 0 1 .002-3.048Z"/>
</svg>

After

Width:  |  Height:  |  Size: 838 B

View File

@ -903,6 +903,8 @@ tr.button-transparent {
cursor: pointer;
width: fit-content;
height: fit-content;
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, scale 0.05s ease-in-out,
outline 0.2s ease-in-out;
text-decoration: none;

View File

@ -1,6 +1,6 @@
<template>
<button class="code" :class="{ copied }" title="Copy code to clipboard" @click="copyText">
{{ text }}
<span>{{ text }}</span>
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
</button>
@ -51,6 +51,12 @@ export default {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
span {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
svg {
width: 1em;
height: 1em;

View File

@ -7,7 +7,7 @@
v-else-if="
!['resourcepack', 'shader'].includes(type) &&
!(type === 'plugin' && search) &&
!categories.some((x) => $tag.loaderData.dataPackLoaders.includes(x))
!categories.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
class="environment"
>
@ -47,57 +47,52 @@
</template>
</span>
</template>
<script>
<script setup>
import InfoIcon from '~/assets/images/utils/info.svg'
import ClientIcon from '~/assets/images/utils/client.svg'
import GlobeIcon from '~/assets/images/utils/globe.svg'
import ServerIcon from '~/assets/images/utils/server.svg'
export default {
components: {
InfoIcon,
ClientIcon,
ServerIcon,
GlobeIcon,
defineProps({
type: {
type: String,
default: 'mod',
},
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 []
},
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 []
},
},
}
})
const tags = useTags()
</script>
<style lang="scss" scoped>
.environment {

View File

@ -3,7 +3,7 @@
<div
:class="{
shown: actuallyShown,
noblur: !$orElse($cosmetics.advancedRendering, true),
noblur: !$orElse(cosmetics.advancedRendering, true),
}"
class="modal-overlay"
@click="hide"
@ -38,6 +38,11 @@ export default {
default: null,
},
},
setup() {
const cosmetics = useCosmetics()
return { cosmetics }
},
data() {
return {
shown: false,

View File

@ -10,7 +10,7 @@
<Chips
id="project-type"
v-model="projectType"
:items="$tag.projectTypes.map((x) => x.display)"
:items="tags.projectTypes.map((x) => x.display)"
/>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
@ -86,9 +86,14 @@ export default {
default: '',
},
},
setup() {
const tags = useTags()
return { tags }
},
data() {
return {
projectType: this.$tag.projectTypes[0].display,
projectType: this.tags.projectTypes[0].display,
name: '',
slug: '',
description: '',
@ -100,7 +105,7 @@ export default {
this.$refs.modal.hide()
},
getProjectType() {
return this.$tag.projectTypes.find((x) => this.projectType === x.display)
return this.tags.projectTypes.find((x) => this.projectType === x.display)
},
getClientSide() {
switch (this.getProjectType().id) {
@ -137,6 +142,8 @@ export default {
const formData = new FormData()
const auth = await useAuth()
formData.append(
'data',
JSON.stringify({
@ -148,8 +155,8 @@ export default {
initial_versions: [],
team_members: [
{
user_id: this.$auth.user.id,
name: this.$auth.user.username,
user_id: auth.value.user.id,
name: auth.value.user.username,
role: 'Owner',
},
],
@ -167,7 +174,6 @@ export default {
body: formData,
headers: {
'Content-Disposition': formData,
Authorization: this.$auth.token,
},
})
@ -193,7 +199,7 @@ export default {
stopLoading()
},
show() {
this.projectType = this.$tag.projectTypes[0].display
this.projectType = this.tags.projectTypes[0].display
this.name = ''
this.slug = ''
this.description = ''

View File

@ -113,7 +113,6 @@ export default {
await useBaseFetch(`project/${this.project.id}`, {
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
})
this.$refs.modal.hide()

View File

@ -23,7 +23,7 @@
<Multiselect
id="report-type"
v-model="reportType"
:options="$tag.reportTypes"
:options="tags.reportTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:multiple="false"
:searchable="false"
@ -82,6 +82,11 @@ export default {
default: '',
},
},
setup() {
const tags = useTags()
return { tags }
},
data() {
return {
reportType: '',
@ -110,7 +115,6 @@ export default {
await useBaseFetch('report', {
method: 'POST',
body: data,
...this.$defaultHeaders(),
})
this.$refs.modal.hide()

View File

@ -128,14 +128,15 @@ export default {
async proceed() {
startLoading()
try {
await useBaseFetch(`user/${this.$auth.user.id}/payouts`, {
const auth = await useAuth()
await useBaseFetch(`user/${auth.value.user.id}/payouts`, {
method: 'POST',
body: {
amount: Number(this.amount.replace('$', '')),
},
...this.$defaultHeaders(),
})
await useAuth(this.$auth.token)
await useAuth(auth.value.token)
this.$refs.modal.hide()
} catch (err) {

View File

@ -58,7 +58,7 @@
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="$tag.rejectedStatuses.includes(notification.body.new_status)">
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
has been <Badge :type="notification.body.new_status" />
</template>
<template v-else>
@ -106,6 +106,7 @@
:raised="raised"
:messages="getMessages()"
class="thread-summary"
:auth="auth"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<div
@ -221,7 +222,7 @@
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="$cosmetics.developerMode" :text="notification.id" />
<CopyCode v-if="cosmetics.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<nuxt-link
@ -253,7 +254,7 @@
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="$cosmetics.developerMode" :text="notification.id" />
<CopyCode v-if="cosmetics.developerMode" :text="notification.id" />
</div>
</div>
</div>
@ -301,8 +302,15 @@ const props = defineProps({
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const cosmetics = useCosmetics()
const tags = useTags()
const type = computed(() =>
!props.notification.body || props.notification.body.type === 'legacy_markdown'
? null
@ -357,7 +365,6 @@ async function performAction(notification, actionIndex) {
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
...app.$defaultHeaders(),
})
}
} catch (err) {

View File

@ -36,7 +36,7 @@
</p>
<Categories
:categories="
categories.filter((x) => !hideLoaders || !$tag.loaders.find((y) => y.name === x))
categories.filter((x) => !hideLoaders || !tags.loaders.find((y) => y.name === x))
"
:type="type"
class="tags"
@ -209,6 +209,11 @@ export default {
default: null,
},
},
setup() {
const tags = useTags()
return { tags }
},
computed: {
projectTypeDisplay() {
return this.$getProjectTypeForDisplay(this.type, this.categories)

View File

@ -1,5 +1,5 @@
<template>
<div v-if="$auth.user && showInvitation" class="universal-card information invited">
<div v-if="showInvitation" class="universal-card information invited">
<h2>Invitation to join project</h2>
<p>
You've been invited be a member of this project with the role of '{{ currentMember.role }}'.
@ -15,10 +15,9 @@
</div>
<div
v-if="
$auth.user &&
currentMember &&
nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || $tag.rejectedStatuses.includes(project.status))
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="author-actions universal-card"
>
@ -155,6 +154,14 @@ export default {
type: String,
default: '',
},
auth: {
type: Object,
required: true,
},
tags: {
type: Object,
required: true,
},
setProcessing: {
type: Function,
default() {
@ -334,7 +341,7 @@ export default {
},
},
{
hide: !this.$tag.rejectedStatuses.includes(this.project.status),
hide: !this.tags.rejectedStatuses.includes(this.project.status),
condition: true,
title: 'Resubmit for review',
id: 'resubmit-for-review',
@ -363,8 +370,8 @@ export default {
)
},
showInvitation() {
if (this.allMembers && this.$auth) {
const member = this.allMembers.find((x) => x.user.id === this.$auth.user.id)
if (this.allMembers && this.auth) {
const member = this.allMembers.find((x) => x.user.id === this.auth.user.id)
return member && !member.accepted
}
return false

View File

@ -98,9 +98,10 @@ const props = defineProps({
})
const emit = defineEmits(['switch-page'])
const data = useNuxtApp()
const route = useRoute()
const tags = useTags()
const tempLoaders = new Set()
let tempVersions = new Set()
const tempReleaseChannels = new Set()
@ -119,7 +120,7 @@ tempVersions = Array.from(tempVersions)
const loaderFilters = shallowRef(Array.from(tempLoaders))
const gameVersionFilters = shallowRef(
data.$tag.gameVersions.filter((gameVer) => tempVersions.includes(gameVer.version))
tags.value.gameVersions.filter((gameVer) => tempVersions.includes(gameVer.version))
)
const versionTypeFilters = shallowRef(Array.from(tempReleaseChannels))
const includeSnapshots = ref(route.query.s === 'true')

View File

@ -63,10 +63,11 @@
class="thread-summary"
:raised="raised"
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
:auth="auth"
/>
<div class="reporter-info">
<ReportIcon class="inline-svg" /> Reported by
<span v-if="$auth.user.id === report.reporterUser.id">you</span>
<span v-if="auth.user.id === report.reporterUser.id">you</span>
<nuxt-link v-else :to="`/user/${report.reporterUser.username}`" class="iconified-link">
<Avatar
:src="report.reporterUser.avatar_url"
@ -81,7 +82,7 @@
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
fromNow(report.created)
}}</span>
<CopyCode v-if="$cosmetics.developerMode" :text="report.id" class="report-id" />
<CopyCode v-if="cosmetics.developerMode" :text="report.id" class="report-id" />
</div>
</div>
</template>
@ -117,7 +118,13 @@ defineProps({
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const cosmetics = useCosmetics()
</script>
<style lang="scss" scoped>

View File

@ -7,11 +7,16 @@
:link-stack="breadcrumbsStack"
/>
<h2>Report details</h2>
<ReportInfo :report="report" :show-thread="false" :show-message="false" />
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
</section>
<section class="universal-card">
<h2>Messages</h2>
<ConversationThread :thread="thread" :report="report" :update-thread="updateThread" />
<ConversationThread
:thread="thread"
:report="report"
:update-thread="updateThread"
:auth="auth"
/>
</section>
</div>
</template>
@ -30,10 +35,12 @@ const props = defineProps({
type: Array,
default: null,
},
auth: {
type: Object,
required: true,
},
})
const app = useNuxtApp()
const report = ref(null)
await fetchReport().then((result) => {
@ -41,7 +48,7 @@ await fetchReport().then((result) => {
})
const { data: rawThread } = await useAsyncData(`thread/${report.value.thread_id}`, () =>
useBaseFetch(`thread/${report.value.thread_id}`, app.$defaultHeaders())
useBaseFetch(`thread/${report.value.thread_id}`)
)
const thread = computed(() => addReportMessage(rawThread.value, report.value))
@ -52,7 +59,7 @@ async function updateThread(newThread) {
async function fetchReport() {
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`, app.$defaultHeaders())
useBaseFetch(`report/${props.reportId}`)
)
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, '')
@ -67,7 +74,7 @@ async function fetchReport() {
let users = []
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${JSON.stringify(userIds)}`, app.$defaultHeaders())
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`)
)
users = usersVal.value
}
@ -75,7 +82,7 @@ async function fetchReport() {
let version = null
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`, app.$defaultHeaders())
useBaseFetch(`version/${versionId}`)
)
version = versionVal.value
}
@ -89,7 +96,7 @@ async function fetchReport() {
let project = null
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`, app.$defaultHeaders())
useBaseFetch(`project/${projectId}`)
)
project = projectVal.value
}

View File

@ -3,7 +3,7 @@
<ReportInfo
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === $auth.user.id) &&
(moderation || x.reporterUser.id === auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open)
)"
:key="report.id"
@ -11,6 +11,7 @@
:thread="report.thread"
:moderation="moderation"
raised
:auth="auth"
class="universal-card recessed"
/>
</template>
@ -24,16 +25,16 @@ defineProps({
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const app = useNuxtApp()
const viewMode = ref('open')
const reports = ref([])
let { data: rawReports } = await useAsyncData('report', () =>
useBaseFetch('report', app.$defaultHeaders())
)
let { data: rawReports } = await useAsyncData('report', () => useBaseFetch('report'))
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, '')
@ -53,13 +54,13 @@ const threadIds = [
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${JSON.stringify(userIds)}`, app.$defaultHeaders())
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`)
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
useBaseFetch(`versions?ids=${JSON.stringify(versionIds)}`, app.$defaultHeaders())
useBaseFetch(`versions?ids=${encodeURIComponent(JSON.stringify(versionIds))}`)
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
useBaseFetch(`threads?ids=${JSON.stringify(threadIds)}`, app.$defaultHeaders())
useBaseFetch(`threads?ids=${encodeURIComponent(JSON.stringify(threadIds))}`)
),
])
@ -70,7 +71,7 @@ const versionProjects = versions.value.map((version) => version.project_id)
const projectIds = [...new Set(reportedProjects.concat(versionProjects))]
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
useBaseFetch(`projects?ids=${JSON.stringify(projectIds)}`, app.$defaultHeaders())
useBaseFetch(`projects?ids=${encodeURIComponent(JSON.stringify(projectIds))}`)
)
reports.value = rawReports.map((report) => {

View File

@ -23,10 +23,15 @@ export default {
required: true,
},
},
setup() {
const tags = useTags()
return { tags }
},
computed: {
categoriesFiltered() {
return this.$tag.categories
.concat(this.$tag.loaders)
return this.tags.categories
.concat(this.tags.loaders)
.filter(
(x) =>
this.categories.includes(x.name) && (!x.project_type || x.project_type === this.type)

View File

@ -33,7 +33,7 @@
</div>
</div>
</Modal>
<div v-if="$cosmetics.developerMode" class="thread-id">
<div v-if="cosmetics.developerMode" class="thread-id">
Thread ID: <CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="messages universal-card recessed">
@ -77,7 +77,7 @@
>
<SendIcon /> Send
</button>
<template v-if="currentMember && !isStaff($auth.user)">
<template v-if="currentMember && !isStaff(auth.user)">
<template v-if="isRejected(project)">
<button
v-if="replyBody"
@ -98,7 +98,7 @@
<div class="spacer"></div>
<div class="input-group extra-options">
<template v-if="report">
<template v-if="isStaff($auth.user)">
<template v-if="isStaff(auth.user)">
<button
v-if="replyBody"
class="iconified-button danger-button"
@ -112,7 +112,7 @@
</template>
</template>
<template v-if="project">
<template v-if="isStaff($auth.user)">
<template v-if="isStaff(auth.user)">
<button
v-if="replyBody"
class="iconified-button brand-button"
@ -216,8 +216,13 @@ const props = defineProps({
return null
},
},
auth: {
type: Object,
required: true,
},
})
const app = useNuxtApp()
const cosmetics = useCosmetics()
const members = computed(() => {
const members = {}
@ -250,7 +255,7 @@ async function updateThreadLocal() {
}
let thread = null
if (threadId) {
thread = await useBaseFetch(`thread/${threadId}`, app.$defaultHeaders())
thread = await useBaseFetch(`thread/${threadId}`)
}
props.updateThread(thread)
}
@ -265,7 +270,6 @@ async function sendReply(status = null) {
body: replyBody.value,
},
},
...app.$defaultHeaders(),
})
replyBody.value = ''
await updateThreadLocal()
@ -293,7 +297,6 @@ async function closeReport(reply) {
body: {
closed: true,
},
...app.$defaultHeaders(),
})
await updateThreadLocal()
} catch (err) {

View File

@ -51,6 +51,10 @@ const props = defineProps({
return []
},
},
auth: {
type: Object,
required: true,
},
})
const app = useNuxtApp()
@ -60,7 +64,7 @@ const members = computed(() => {
for (const member of props.thread.members) {
members[member.id] = member
}
members[app.$auth.user.id] = app.$auth.user
members[props.auth.user.id] = props.auth.user
return members
})

View File

@ -16,8 +16,12 @@ export const initAuth = async (oldToken = null) => {
const auth = {
user: null,
token: '',
headers: {},
}
if (oldToken === 'none') {
return auth
}
const route = useRoute()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
@ -31,33 +35,66 @@ export const initAuth = async (oldToken = null) => {
authCookie.value = oldToken
}
if (route.query.code) {
if (route.query.code && !route.fullPath.includes('new_account=true')) {
authCookie.value = route.query.code
}
if (authCookie.value) {
auth.token = authCookie.value
try {
auth.user = await useBaseFetch('user', {
headers: {
Authorization: auth.token,
},
})
} catch {}
auth.headers = {
headers: {
Authorization: auth.token,
},
if (!auth.token || !auth.token.startsWith('mra_')) {
return auth
}
try {
auth.user = await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
},
},
true
)
} catch {}
}
if (!auth.user && auth.token) {
try {
const session = await useBaseFetch(
'session/refresh',
{
method: 'POST',
headers: {
Authorization: auth.token,
},
},
true
)
auth.token = session.session
authCookie.value = auth.token
auth.user = await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
},
},
true
)
} catch {
authCookie.value = null
}
}
return auth
}
export const getAuthUrl = () => {
export const getAuthUrl = (provider) => {
const config = useRuntimeConfig()
const route = useRoute()
return `${config.public.apiBaseUrl}auth/init?url=${config.public.siteUrl}${route.fullPath}`
return `${config.public.apiBaseUrl}auth/init?url=${config.public.siteUrl}${route.path}&provider=${provider}`
}

View File

@ -1,13 +1,19 @@
export const useBaseFetch = async (url, options = {}) => {
export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
const config = useRuntimeConfig()
const base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (options.headers && process.server) {
if (!options.headers) {
options.headers = {}
}
if (process.server) {
options.headers['x-ratelimit-key'] = config.rateLimitKey
} else if (process.server) {
options.headers = {
'x-ratelimit-key': config.rateLimitKey,
}
}
if (!skipAuth) {
const auth = await useAuth()
options.headers.Authorization = auth.value.token
}
return await $fetch(`${base}${url}`, options)

View File

@ -20,8 +20,8 @@ export const initUser = async () => {
if (auth.user && auth.user.id) {
try {
const [notifications, follows] = await Promise.all([
useBaseFetch(`user/${auth.user.id}/notifications`, auth.headers),
useBaseFetch(`user/${auth.user.id}/follows`, auth.headers),
useBaseFetch(`user/${auth.user.id}/notifications`),
useBaseFetch(`user/${auth.user.id}/follows`),
])
user.notifications = notifications
@ -41,7 +41,7 @@ export const initUserNotifs = async () => {
if (auth.user && auth.user.id) {
try {
user.notifications = await useBaseFetch(`user/${auth.user.id}/notifications`, auth.headers)
user.notifications = await useBaseFetch(`user/${auth.user.id}/notifications`)
} catch (err) {
console.error(err)
}
@ -54,7 +54,7 @@ export const initUserFollows = async () => {
if (auth.user && auth.user.id) {
try {
user.follows = await useBaseFetch(`user/${auth.user.id}/follows`, auth.headers)
user.follows = await useBaseFetch(`user/${auth.user.id}/follows`)
} catch (err) {
console.error(err)
}
@ -67,7 +67,7 @@ export const initUserProjects = async () => {
if (auth.user && auth.user.id) {
try {
user.projects = await useBaseFetch(`user/${auth.user.id}/projects`, auth.headers)
user.projects = await useBaseFetch(`user/${auth.user.id}/projects`)
} catch (err) {
console.error(err)
}
@ -75,7 +75,6 @@ export const initUserProjects = async () => {
}
export const userFollowProject = async (project) => {
const auth = (await useAuth()).value
const user = (await useUser()).value
user.follows = user.follows.concat(project)
@ -84,13 +83,11 @@ export const userFollowProject = async (project) => {
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: 'POST',
...auth.headers,
})
})
}
export const userUnfollowProject = async (project) => {
const auth = (await useAuth()).value
const user = (await useUser()).value
user.follows = user.follows.filter((x) => x.id !== project.id)
@ -99,7 +96,6 @@ export const userUnfollowProject = async (project) => {
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: 'DELETE',
...auth.headers,
})
})
}
@ -126,3 +122,45 @@ export const userReadNotifications = async (ids) => {
return x
})
}
export const resendVerifyEmail = async () => {
const app = useNuxtApp()
startLoading()
try {
await useBaseFetch('auth/email/resend_verify', {
method: 'POST',
})
const auth = await useAuth()
app.$notify({
group: 'main',
title: 'Email sent',
text: `An email with a link to verify your account has been sent to ${auth.value.user.email}.`,
type: 'success',
})
} catch (err) {
app.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
export const logout = async () => {
startLoading()
const auth = await useAuth()
try {
await useBaseFetch(`session/${auth.value.token}`, {
method: 'DELETE',
})
} catch {}
await useAuth('none')
useCookie('auth-token').value = null
await navigateTo('/')
stopLoading()
}

View File

@ -2,13 +2,12 @@ import { useNuxtApp } from '#app'
import { userReadNotifications } from '~/composables/user.js'
async function getBulk(type, ids) {
const auth = (await useAuth()).value
if (ids.length === 0) {
return []
}
const url = `${type}?ids=${JSON.stringify([...new Set(ids)])}`
const { data: bulkFetch } = await useAsyncData(url, () => useBaseFetch(url, auth.headers))
const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`
const { data: bulkFetch } = await useAsyncData(url, () => useBaseFetch(url))
return bulkFetch.value
}
@ -16,7 +15,7 @@ export async function fetchNotifications() {
try {
const auth = (await useAuth()).value
const { data: notifications } = await useAsyncData(`user/${auth.user.id}/notifications`, () =>
useBaseFetch(`user/${auth.user.id}/notifications`, auth.headers)
useBaseFetch(`user/${auth.user.id}/notifications`)
)
const projectIds = []
@ -161,12 +160,8 @@ export function groupNotifications(notifications, includeRead = false) {
export async function markAsRead(ids) {
try {
const auth = (await useAuth()).value
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
method: 'PATCH',
headers: {
Authorization: auth.token,
},
})
await userReadNotifications(ids)
return (notifications) => {

View File

@ -1,20 +1,21 @@
export const getProjectTypeForUrl = (type, categories) => {
const app = useNuxtApp()
return getProjectTypeForUrlShorthand(app, type, categories)
return getProjectTypeForUrlShorthand(type, categories)
}
export const getProjectTypeForUrlShorthand = (app, type, categories) => {
export const getProjectTypeForUrlShorthand = (type, categories, overrideTags) => {
const tags = overrideTags ?? useTags().value
if (type === 'mod') {
const isMod = categories.some((category) => {
return app.$tag.loaderData.modLoaders.includes(category)
return tags.loaderData.modLoaders.includes(category)
})
const isPlugin = categories.some((category) => {
return app.$tag.loaderData.allPluginLoaders.includes(category)
return tags.loaderData.allPluginLoaders.includes(category)
})
const isDataPack = categories.some((category) => {
return app.$tag.loaderData.dataPackLoaders.includes(category)
return tags.loaderData.dataPackLoaders.includes(category)
})
if (isDataPack) {

View File

@ -1,18 +1,14 @@
export const acceptTeamInvite = async (teamId) => {
const app = useNuxtApp()
await useBaseFetch(`team/${teamId}/join`, {
method: 'POST',
...app.$defaultHeaders(),
})
}
export const removeSelfFromTeam = async (teamId) => {
const app = useNuxtApp()
await removeTeamMember(teamId, app.$auth.user.id)
const auth = await useAuth()
await removeTeamMember(teamId, auth.user.id)
}
export const removeTeamMember = async (teamId, userId) => {
const app = useNuxtApp()
await useBaseFetch(`team/${teamId}/members/${userId}`, {
method: 'DELETE',
...app.$defaultHeaders(),
})
}

View File

@ -1,5 +1,21 @@
<template>
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
<div
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
class="email-nag"
>
<template v-if="auth.user.email">
<span>For security purposes, please verify your email address on Modrinth.</span>
<button class="btn" @click="resendVerifyEmail">Re-send verification email</button>
</template>
<template v-else>
<span>For security purposes, please enter your email on Modrinth.</span>
<nuxt-link class="btn" to="/settings/account">
<SettingsIcon />
Visit account settings
</nuxt-link>
</template>
</div>
<header class="site-header" role="presentation">
<section class="navbar columns" role="navigation">
<section class="logo column" role="presentation">
@ -25,7 +41,6 @@
<button
class="control-button button-transparent"
title="Switch theme"
:disabled="isThemeSwitchOnHold"
@click="changeTheme"
>
<MoonIcon v-if="$colorMode.value === 'light'" aria-hidden="true" />
@ -80,7 +95,7 @@
<span class="title">Settings</span>
</NuxtLink>
<NuxtLink
v-if="$tag.staffRoles.includes($auth.user.role)"
v-if="tags.staffRoles.includes(auth.user.role)"
class="item button-transparent"
to="/moderation"
>
@ -88,21 +103,16 @@
<span class="title">Moderation</span>
</NuxtLink>
<hr class="divider" />
<button class="item button-transparent" @click="logout()">
<button class="item button-transparent" @click="logoutUser()">
<LogOutIcon class="icon" />
<span class="dropdown-item__text">Log out</span>
</button>
</div>
</div>
<section v-else class="auth-prompt">
<a
:href="getAuthUrl()"
class="log-in-button header-button brand-button"
rel="noopener nofollow"
>
<GitHubIcon aria-hidden="true" />
Sign in with GitHub</a
>
<nuxt-link class="iconified-button brand-button" to="/auth/sign-in">
<LogInIcon /> Sign in
</nuxt-link>
</section>
</section>
</section>
@ -150,19 +160,13 @@
<div>Visit your profile</div>
</div>
</NuxtLink>
<a
v-else
class="iconified-button brand-button"
:href="getAuthUrl()"
rel="nofollow noopener"
>
<GitHubIcon aria-hidden="true" />
Sign in with GitHub
</a>
<nuxt-link v-else class="iconified-button brand-button" to="/auth/sign-in">
<LogInIcon /> Sign in
</nuxt-link>
</div>
<div class="links">
<template v-if="auth.user">
<button class="iconified-button danger-button" @click="logout()">
<button class="iconified-button danger-button" @click="logoutUser()">
<LogOutIcon aria-hidden="true" />
Log out
</button>
@ -187,7 +191,7 @@
<SettingsIcon aria-hidden="true" />
Settings
</NuxtLink>
<button class="iconified-button" :disabled="isThemeSwitchOnHold" @click="changeTheme">
<button class="iconified-button" @click="changeTheme">
<MoonIcon v-if="$colorMode.value === 'light'" class="icon" />
<SunIcon v-else class="icon" />
<span class="dropdown-item__text">Change theme</span>
@ -319,11 +323,7 @@
</a>
</div>
<div class="buttons">
<button
class="iconified-button raised-button"
:disabled="isThemeSwitchOnHold"
@click="changeTheme"
>
<button class="iconified-button raised-button" @click="changeTheme">
<MoonIcon v-if="$colorMode.value === 'light'" aria-hidden="true" />
<SunIcon v-else aria-hidden="true" />
Change theme
@ -340,6 +340,7 @@
</div>
</template>
<script setup>
import { LogInIcon } from 'omorphia'
import HamburgerIcon from '~/assets/images/utils/hamburger.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import SearchIcon from '~/assets/images/utils/search.svg'
@ -357,7 +358,6 @@ import LogOutIcon from '~/assets/images/utils/log-out.svg'
import HeartIcon from '~/assets/images/utils/heart.svg'
import ChartIcon from '~/assets/images/utils/chart.svg'
import GitHubIcon from '~/assets/images/utils/github.svg'
import NavRow from '~/components/ui/NavRow.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import Avatar from '~/components/ui/Avatar.vue'
@ -365,6 +365,8 @@ import Avatar from '~/components/ui/Avatar.vue'
const app = useNuxtApp()
const auth = await useAuth()
const user = await useUser()
const cosmetics = useCosmetics()
const tags = useTags()
const config = useRuntimeConfig()
const route = useRoute()
@ -383,9 +385,9 @@ let developerModeCounter = 0
function developerModeIncrement() {
if (developerModeCounter >= 5) {
app.$cosmetics.developerMode = !app.$cosmetics.developerMode
cosmetics.value.developerMode = !cosmetics.value.developerMode
developerModeCounter = 0
if (app.$cosmetics.developerMode) {
if (cosmetics.value.developerMode) {
app.$notify({
group: 'main',
title: 'Developer mode activated',
@ -404,6 +406,10 @@ function developerModeIncrement() {
developerModeCounter++
}
}
async function logoutUser() {
await logout()
}
</script>
<script>
export default defineNuxtComponent({
@ -412,7 +418,6 @@ export default defineNuxtComponent({
isDropdownOpen: false,
isMobileMenuOpen: false,
isBrowseMenuOpen: false,
isThemeSwitchOnHold: false,
registeredSkipLink: null,
hideDropdown: false,
navRoutes: [
@ -463,12 +468,8 @@ export default defineNuxtComponent({
this.runAnalytics()
},
},
async mounted() {
mounted() {
this.runAnalytics()
if (this.$route.query.code) {
await useAuth(this.$route.query.code)
window.history.replaceState(history.state, null, this.$route.path)
}
},
methods: {
runAnalytics() {
@ -498,28 +499,8 @@ export default defineNuxtComponent({
this.isMobileMenuOpen = false
}
},
logout() {
useCookie('auth-token').value = null
this.$notify({
group: 'main',
title: 'Logged Out',
text: 'You have logged out successfully!',
type: 'success',
})
useRouter()
.push('/')
.then(() => {
useRouter().go()
})
},
changeTheme() {
this.isThemeSwitchOnHold = true
updateTheme(this.$colorMode.value === 'dark' ? 'light' : 'dark', true)
setTimeout(() => {
this.isThemeSwitchOnHold = false
}, 1000)
},
},
})
@ -1147,5 +1128,15 @@ export default defineNuxtComponent({
}
}
}
.email-nag {
background-color: var(--color-raised-bg);
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.5rem 1rem;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@ -1,7 +1,7 @@
export default defineNuxtRouteMiddleware(async () => {
export default defineNuxtRouteMiddleware(async (_to, from) => {
const auth = await useAuth()
if (!auth.value.user) {
return navigateTo(getAuthUrl(), { external: true })
return navigateTo(`/auth/sign-in?redirect=${encodeURIComponent(from.fullPath)}`)
}
})

View File

@ -294,12 +294,15 @@ export default defineNuxtConfig({
},
},
},
modules: ['@vintl/nuxt'],
modules: ['@vintl/nuxt', '@nuxtjs/turnstile'],
vintl: {
defaultLocale: 'en-US',
storage: 'cookie',
parserless: 'only-prod',
},
turnstile: {
siteKey: '0x4AAAAAAAHWfmKCm7cUG869',
},
nitro: {
moduleSideEffects: ['@vintl/compact-number/locale-data'],
},

View File

@ -14,6 +14,7 @@
"devDependencies": {
"@formatjs/cli": "^6.1.2",
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"@nuxtjs/turnstile": "^0.5.0",
"@types/node": "^20.1.0",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
@ -44,6 +45,7 @@
"jszip": "^3.10.1",
"markdown-it": "^13.0.1",
"omorphia": "^0.4.31",
"qrcode.vue": "^3.4.0",
"vue-multiselect": "^3.0.0-alpha.2",
"xss": "^1.0.14"
},

View File

@ -106,6 +106,8 @@
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
:all-members="allMembers"
:update-members="updateMembers"
:auth="auth"
:tags="tags"
/>
<NuxtPage
v-model:project="project"
@ -149,7 +151,7 @@
/>
</Head>
<ModalModeration
v-if="$auth.user"
v-if="auth.user"
ref="modalModeration"
:project="project"
:status="moderationStatus"
@ -161,7 +163,7 @@
</div>
</Modal>
<ModalReport
v-if="$auth.user"
v-if="auth.user"
ref="modal_project_report"
:item-id="project.id"
item-type="project"
@ -169,7 +171,7 @@
<div
:class="{
'normal-page': true,
'alt-layout': $cosmetics.projectLayout,
'alt-layout': cosmetics.projectLayout,
}"
>
<div class="normal-page__sidebar">
@ -229,7 +231,7 @@
class="categories"
>
<Badge
v-if="$auth.user && currentMember"
v-if="auth.user && currentMember"
:type="project.status"
class="status-badge"
/>
@ -287,7 +289,7 @@
</div>
<hr class="card-divider" />
<div class="input-group">
<template v-if="$auth.user">
<template v-if="auth.user">
<button class="iconified-button" @click="$refs.modal_project_report.show()">
<ReportIcon aria-hidden="true" />
Report
@ -344,7 +346,7 @@
/>
<div class="buttons status-buttons">
<button
v-if="$tag.approvedStatuses.includes(project.status)"
v-if="tags.approvedStatuses.includes(project.status)"
class="iconified-button"
@click="clearMessage"
>
@ -354,14 +356,14 @@
</div>
</div>
<div
v-if="$auth.user && $tag.staffRoles.includes($auth.user.role)"
v-if="auth.user && tags.staffRoles.includes(auth.user.role)"
class="universal-card moderation-card"
>
<h2>Moderation actions</h2>
<div class="input-stack">
<button
v-if="
!$tag.approvedStatuses.includes(project.status) || project.status === 'processing'
!tags.approvedStatuses.includes(project.status) || project.status === 'processing'
"
class="iconified-button brand-button"
@click="openModerationModal(requestedStatus)"
@ -372,9 +374,9 @@
</button>
<button
v-if="
$tag.approvedStatuses.includes(project.status) ||
tags.approvedStatuses.includes(project.status) ||
project.status === 'processing' ||
($tag.rejectedStatuses.includes(project.status) && project.status !== 'withheld')
(tags.rejectedStatuses.includes(project.status) && project.status !== 'withheld')
"
class="iconified-button danger-button"
@click="openModerationModal('withheld')"
@ -384,9 +386,9 @@
</button>
<button
v-if="
$tag.approvedStatuses.includes(project.status) ||
tags.approvedStatuses.includes(project.status) ||
project.status === 'processing' ||
($tag.rejectedStatuses.includes(project.status) && project.status !== 'rejected')
(tags.rejectedStatuses.includes(project.status) && project.status !== 'rejected')
"
class="iconified-button danger-button"
@click="openModerationModal('rejected')"
@ -422,6 +424,8 @@
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
:all-members="allMembers"
:update-members="updateMembers"
:auth="auth"
:tags="tags"
/>
<div v-else-if="project.status === 'withheld'" class="card warning" aria-label="Warning">
{{ project.title }} has been removed from search by Modrinth's moderators. Please use
@ -447,7 +451,7 @@
Prism Launcher</a
>.
</div>
<Promotion v-if="$tag.approvedStatuses.includes(project.status)" />
<Promotion v-if="tags.approvedStatuses.includes(project.status)" />
<div class="navigation-card">
<NavRow
:links="[
@ -485,7 +489,7 @@
},
]"
/>
<div v-if="$auth.user && currentMember" class="input-group">
<div v-if="auth.user && currentMember" class="input-group">
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="iconified-button"
@ -808,12 +812,15 @@ const data = useNuxtApp()
const route = useRoute()
const config = useRuntimeConfig()
const auth = await useAuth()
const user = await useUser()
const cosmetics = useCosmetics()
const tags = useTags()
if (
!route.params.id ||
!(
data.$tag.projectTypes.find((x) => x.id === route.params.type) ||
tags.value.projectTypes.find((x) => x.id === route.params.type) ||
route.params.type === 'project'
)
) {
@ -833,28 +840,27 @@ try {
{ data: featuredVersions },
{ data: versions },
] = await Promise.all([
useAsyncData(
`project/${route.params.id}`,
() => useBaseFetch(`project/${route.params.id}`, data.$defaultHeaders()),
{
transform: (project) => {
if (project) {
project.actualProjectType = JSON.parse(JSON.stringify(project.project_type))
useAsyncData(`project/${route.params.id}`, () => useBaseFetch(`project/${route.params.id}`), {
transform: (project) => {
if (project) {
project.actualProjectType = JSON.parse(JSON.stringify(project.project_type))
project.project_type = data.$getProjectTypeForUrl(
project.project_type,
project.loaders,
tags.value
)
project.project_type = data.$getProjectTypeForUrl(project.project_type, project.loaders)
if (process.client && history.state && history.state.overrideProjectType) {
project.project_type = history.state.overrideProjectType
}
if (process.client && history.state && history.state.overrideProjectType) {
project.project_type = history.state.overrideProjectType
}
}
return project
},
}
),
return project
},
}),
useAsyncData(
`project/${route.params.id}/members`,
() => useBaseFetch(`project/${route.params.id}/members`, data.$defaultHeaders()),
() => useBaseFetch(`project/${route.params.id}/members`),
{
transform: (members) => {
members.forEach((it, index) => {
@ -867,13 +873,13 @@ try {
}
),
useAsyncData(`project/${route.params.id}/dependencies`, () =>
useBaseFetch(`project/${route.params.id}/dependencies`, data.$defaultHeaders())
useBaseFetch(`project/${route.params.id}/dependencies`)
),
useAsyncData(`project/${route.params.id}/version?featured=true`, () =>
useBaseFetch(`project/${route.params.id}/version?featured=true`, data.$defaultHeaders())
useBaseFetch(`project/${route.params.id}/version?featured=true`)
),
useAsyncData(`project/${route.params.id}/version`, () =>
useBaseFetch(`project/${route.params.id}/version`, data.$defaultHeaders())
useBaseFetch(`project/${route.params.id}/version`)
),
])
@ -910,23 +916,23 @@ if (project.value.project_type !== route.params.type || route.params.id !== proj
const members = ref(allMembers.value.filter((x) => x.accepted))
const currentMember = ref(
data.$auth.user ? allMembers.value.find((x) => x.user.id === data.$auth.user.id) : null
auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null
)
if (
!currentMember.value &&
data.$auth.user &&
data.$tag.staffRoles.includes(data.$auth.user.role)
auth.value.user &&
tags.value.staffRoles.includes(auth.value.user.role)
) {
currentMember.value = {
team_id: project.team_id,
user: data.$auth.user,
role: data.$auth.role,
permissions: data.$auth.user.role === 'admin' ? 1023 : 12,
user: auth.value.user,
role: auth.value.role,
permissions: auth.value.user.role === 'admin' ? 1023 : 12,
accepted: true,
payouts_split: 0,
avatar_url: data.$auth.user.avatar_url,
name: data.$auth.user.username,
avatar_url: auth.value.user.avatar_url,
name: auth.value.user.username,
}
}
@ -943,7 +949,7 @@ featuredVersions.value = versions.value.filter((version) => featuredIds.includes
featuredVersions.value.sort((a, b) => {
const aLatest = a.game_versions[a.game_versions.length - 1]
const bLatest = b.game_versions[b.game_versions.length - 1]
const gameVersions = data.$tag.gameVersions.map((e) => e.version)
const gameVersions = tags.value.gameVersions.map((e) => e.version)
return gameVersions.indexOf(aLatest) - gameVersions.indexOf(bLatest)
})
@ -967,7 +973,7 @@ const featuredGalleryImage = computed(() => project.value.gallery.find((img) =>
const requestedStatus = computed(() => project.value.requested_status ?? 'approved')
async function resetProject() {
const newProject = await useBaseFetch(`project/${project.value.id}`, data.$defaultHeaders())
const newProject = await useBaseFetch(`project/${project.value.id}`)
newProject.actualProjectType = JSON.parse(JSON.stringify(newProject.project_type))
@ -986,7 +992,6 @@ async function clearMessage() {
moderation_message: null,
moderation_message_body: null,
},
...data.$defaultHeaders(),
})
project.value.moderator_message = null
@ -1011,7 +1016,6 @@ async function setProcessing() {
body: {
status: 'processing',
},
...data.$defaultHeaders(),
})
project.value.status = 'processing'
@ -1048,7 +1052,6 @@ async function patchProject(resData, quiet = false) {
await useBaseFetch(`project/${project.value.id}`, {
method: 'PATCH',
body: resData,
...data.$defaultHeaders(),
})
for (const key in resData) {
@ -1099,7 +1102,6 @@ async function patchIcon(icon) {
{
method: 'PATCH',
body: icon,
...data.$defaultHeaders(),
}
)
await resetProject()
@ -1136,7 +1138,7 @@ function openModerationModal(status) {
async function updateMembers() {
allMembers.value = await useAsyncData(
`project/${route.params.id}/members`,
() => useBaseFetch(`project/${route.params.id}/members`, data.$defaultHeaders()),
() => useBaseFetch(`project/${route.params.id}/members`),
{
transform: (members) => {
members.forEach((it, index) => {

View File

@ -8,7 +8,7 @@
<Meta name="og:description" :contcent="metaDescription" />
</Head>
<Modal
v-if="$auth.user && currentMember"
v-if="currentMember"
ref="modal_edit_item"
:header="editIndex === -1 ? 'Upload gallery image' : 'Edit gallery item'"
>
@ -127,7 +127,7 @@
</div>
</Modal>
<ModalConfirm
v-if="$auth.user && currentMember"
v-if="currentMember"
ref="modal_confirm"
title="Are you sure you want to delete this gallery image?"
description="This will remove this gallery image forever (like really forever)."
@ -446,7 +446,6 @@ export default defineNuxtComponent({
await useBaseFetch(url, {
method: 'POST',
body: this.editFile,
...this.$defaultHeaders(),
})
await this.updateProject()
@ -484,7 +483,6 @@ export default defineNuxtComponent({
await useBaseFetch(url, {
method: 'PATCH',
...this.$defaultHeaders(),
})
await this.updateProject()
@ -511,7 +509,6 @@ export default defineNuxtComponent({
)}`,
{
method: 'DELETE',
...this.$defaultHeaders(),
}
)
@ -528,7 +525,7 @@ export default defineNuxtComponent({
stopLoading()
},
async updateProject() {
const project = await useBaseFetch(`project/${this.project.id}`, this.$defaultHeaders())
const project = await useBaseFetch(`project/${this.project.id}`)
project.actualProjectType = JSON.parse(JSON.stringify(project.project_type))

View File

@ -68,6 +68,7 @@
:project="project"
:set-status="setStatus"
:current-member="currentMember"
:auth="auth"
/>
</section>
</div>
@ -104,9 +105,10 @@ const props = defineProps({
const emit = defineEmits(['update:project'])
const app = useNuxtApp()
const auth = await useAuth()
const { data: thread } = await useAsyncData(`thread/${props.project.thread_id}`, () =>
useBaseFetch(`thread/${props.project.thread_id}`, app.$defaultHeaders())
useBaseFetch(`thread/${props.project.thread_id}`)
)
async function setStatus(status) {
startLoading()
@ -117,12 +119,11 @@ async function setStatus(status) {
await useBaseFetch(`project/${props.project.id}`, {
method: 'PATCH',
body: data,
...app.$defaultHeaders(),
})
const project = props.project
project.status = status
emit('update:project', project)
thread.value = await useBaseFetch(`thread/${thread.value.id}`, app.$defaultHeaders())
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
} catch (err) {
app.$notify({
group: 'main',

View File

@ -191,7 +191,7 @@
id="project-visibility"
v-model="visibility"
placeholder="Select one"
:options="$tag.approvedStatuses"
:options="tags.approvedStatuses"
:custom-label="(value) => $formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
@ -315,6 +315,11 @@ export default defineNuxtComponent({
},
},
},
setup() {
const tags = useTags()
return { tags }
},
data() {
return {
name: this.project.title,
@ -325,7 +330,7 @@ export default defineNuxtComponent({
clientSide: this.project.client_side,
serverSide: this.project.server_side,
deletedIcon: false,
visibility: this.$tag.approvedStatuses.includes(this.project.status)
visibility: this.tags.approvedStatuses.includes(this.project.status)
? this.project.status
: this.project.requested_status,
}
@ -360,7 +365,7 @@ export default defineNuxtComponent({
if (this.serverSide !== this.project.server_side) {
data.server_side = this.serverSide
}
if (this.$tag.approvedStatuses.includes(this.project.status)) {
if (this.tags.approvedStatuses.includes(this.project.status)) {
if (this.visibility !== this.project.status) {
data.status = this.visibility
}
@ -376,7 +381,7 @@ export default defineNuxtComponent({
},
methods: {
hasModifiedVisibility() {
const originalVisibility = this.$tag.approvedStatuses.includes(this.project.status)
const originalVisibility = this.tags.approvedStatuses.includes(this.project.status)
? this.project.status
: this.project.requested_status
@ -407,7 +412,6 @@ export default defineNuxtComponent({
async deleteProject() {
await useBaseFetch(`project/${this.project.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await initUserProjects()
await this.$router.push('/dashboard/review')
@ -426,7 +430,6 @@ export default defineNuxtComponent({
async deleteIcon() {
await useBaseFetch(`project/${this.project.id}/icon`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await this.updateIcon()
this.$notify({

View File

@ -96,7 +96,7 @@
<Multiselect
v-model="donationLink.platform"
placeholder="Select platform"
:options="$tag.donationPlatforms.map((x) => x.name)"
:options="tags.donationPlatforms.map((x) => x.name)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
@ -155,6 +155,11 @@ export default defineNuxtComponent({
},
},
},
setup() {
const tags = useTags()
return { tags }
},
data() {
const donationLinks = JSON.parse(JSON.stringify(this.project.donation_urls))
donationLinks.push({
@ -195,7 +200,7 @@ export default defineNuxtComponent({
const donationLinks = this.donationLinks.filter((link) => link.url && link.platform)
donationLinks.forEach((link) => {
link.id = this.$tag.donationPlatforms.find(
link.id = this.tags.donationPlatforms.find(
(platform) => platform.name === link.platform
).short
})

View File

@ -323,7 +323,6 @@ export default defineNuxtComponent({
await useBaseFetch(`team/${this.project.team}/members`, {
method: 'POST',
body: data,
...this.$defaultHeaders(),
})
this.currentUsername = ''
await this.updateMembers()
@ -346,7 +345,6 @@ export default defineNuxtComponent({
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
{
method: 'DELETE',
...this.$defaultHeaders(),
}
)
await this.updateMembers()
@ -381,7 +379,6 @@ export default defineNuxtComponent({
{
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
}
)
await this.updateMembers()
@ -411,7 +408,6 @@ export default defineNuxtComponent({
body: {
user_id: this.allTeamMembers[index].user.id,
},
...this.$defaultHeaders(),
})
await this.updateMembers()
} catch (err) {
@ -426,9 +422,7 @@ export default defineNuxtComponent({
stopLoading()
},
async updateMembers() {
this.allTeamMembers = (
await useBaseFetch(`team/${this.project.team}/members`, this.$defaultHeaders())
).map((it) => ({
this.allTeamMembers = (await useBaseFetch(`team/${this.project.team}/members`)).map((it) => ({
avatar_url: it.user.avatar_url,
name: it.user.username,
oldRole: it.role,

View File

@ -152,13 +152,13 @@ export default defineNuxtComponent({
},
data() {
return {
selectedTags: this.$sortedCategories.filter(
selectedTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name))
),
featuredTags: this.$sortedCategories.filter(
featuredTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name)
@ -168,7 +168,7 @@ export default defineNuxtComponent({
computed: {
categoryLists() {
const lists = {}
this.$sortedCategories.forEach((x) => {
this.$sortedCategories().forEach((x) => {
if (x.project_type === this.project.actualProjectType) {
const header = x.header
if (!lists[header]) {

View File

@ -8,7 +8,7 @@
<Meta name="og:description" :content="metaDescription" />
</Head>
<ModalConfirm
v-if="$auth.user && currentMember"
v-if="currentMember"
ref="modal_confirm"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
@ -17,12 +17,12 @@
@proceed="deleteVersion()"
/>
<ModalReport
v-if="$auth.user"
v-if="auth.user"
ref="modal_version_report"
:item-id="version.id"
item-type="version"
/>
<Modal v-if="$auth.user && currentMember" ref="modal_package_mod" header="Package data pack">
<Modal v-if="auth.user && currentMember" ref="modal_package_mod" header="Package data pack">
<div class="modal-package-mod universal-labels">
<div class="markdown-body">
<p>
@ -116,7 +116,7 @@
Create
</button>
<nuxt-link
v-if="$auth.user"
v-if="auth.user"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
class="iconified-button"
>
@ -164,7 +164,7 @@
<ReportIcon aria-hidden="true" />
Report
</button>
<a v-if="!$auth.user" class="iconified-button" :href="getAuthUrl()" rel="noopener nofollow">
<a v-if="!auth.user" class="iconified-button" :href="getAuthUrl()" rel="noopener nofollow">
<ReportIcon aria-hidden="true" />
Report
</a>
@ -181,7 +181,7 @@
<button
v-if="
currentMember &&
version.loaders.some((x) => $tag.loaderData.dataPackLoaders.includes(x))
version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
class="iconified-button"
@click="$refs.modal_package_mod.show()"
@ -390,7 +390,7 @@
</span>
<multiselect
v-if="
version.loaders.some((x) => $tag.loaderData.dataPackLoaders.includes(x)) &&
version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x)) &&
isEditing &&
primaryFile.hashes.sha1 !== file.hashes.sha1
"
@ -457,7 +457,7 @@
<span class="file-size">({{ $formatBytes(file.size) }})</span>
</span>
<multiselect
v-if="version.loaders.some((x) => $tag.loaderData.dataPackLoaders.includes(x))"
v-if="version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))"
v-model="newFileTypes[index]"
class="raised-multiselect"
placeholder="Select file type"
@ -484,7 +484,7 @@
</div>
<div class="additional-files">
<h4>Upload additional files</h4>
<span v-if="version.loaders.some((x) => $tag.loaderData.dataPackLoaders.includes(x))">
<span v-if="version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))">
Used for additional files such as required/optional resource packs
</span>
<span v-else>Used for files such as sources or Javadocs.</span>
@ -566,14 +566,14 @@
v-if="isEditing"
v-model="version.loaders"
:options="
$tag.loaders
tags.loaders
.filter((x) =>
x.supported_project_types.includes(project.actualProjectType.toLowerCase())
)
.map((it) => it.name)
"
:custom-label="(value) => $formatCategory(value)"
:loading="$tag.loaders.length === 0"
:loading="tags.loaders.length === 0"
:multiple="true"
:searchable="false"
:show-no-results="false"
@ -593,12 +593,12 @@
v-model="version.game_versions"
:options="
showSnapshots
? $tag.gameVersions.map((x) => x.version)
: $tag.gameVersions
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
"
:loading="$tag.gameVersions.length === 0"
:loading="tags.gameVersions.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
@ -772,6 +772,9 @@ export default defineNuxtComponent({
const data = useNuxtApp()
const route = useRoute()
const auth = await useAuth()
const tags = useTags()
const path = route.name.split('-')
const mode = path[path.length - 1]
@ -828,7 +831,7 @@ export default defineNuxtComponent({
const inferredData = await inferVersionInfo(
replaceFile,
props.project,
data.$tag.gameVersions
tags.value.gameVersions
)
version = {
@ -895,6 +898,8 @@ export default defineNuxtComponent({
const order = ['required', 'optional', 'incompatible', 'embedded']
return {
auth,
tags,
fileTypes: ref(fileTypes),
oldFileTypes: ref(oldFileTypes),
isCreating: ref(isCreating),
@ -1110,7 +1115,6 @@ export default defineNuxtComponent({
body: formData,
headers: {
'Content-Disposition': formData,
Authorization: this.$auth.token,
},
})
}
@ -1135,13 +1139,11 @@ export default defineNuxtComponent({
}
}),
},
...this.$defaultHeaders(),
})
for (const hash of this.deleteFiles) {
await useBaseFetch(`version_file/${hash}?version_id=${this.version.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
}
@ -1246,7 +1248,6 @@ export default defineNuxtComponent({
body: formData,
headers: {
'Content-Disposition': formData,
Authorization: this.$auth.token,
},
})
@ -1263,7 +1264,6 @@ export default defineNuxtComponent({
await useBaseFetch(`version/${this.version.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await this.resetProjectVersions()
@ -1279,7 +1279,7 @@ export default defineNuxtComponent({
this.version,
this.primaryFile,
this.members,
this.$tag.gameVersions,
this.tags.gameVersions,
this.packageLoaders
)
@ -1324,12 +1324,9 @@ export default defineNuxtComponent({
},
async resetProjectVersions() {
const [versions, featuredVersions, dependencies] = await Promise.all([
useBaseFetch(`project/${this.version.project_id}/version`, this.$defaultHeaders()),
useBaseFetch(
`project/${this.version.project_id}/version?featured=true`,
this.$defaultHeaders()
),
useBaseFetch(`project/${this.version.project_id}/dependencies`, this.$defaultHeaders()),
useBaseFetch(`project/${this.version.project_id}/version`),
useBaseFetch(`project/${this.version.project_id}/version?featured=true`),
useBaseFetch(`project/${this.version.project_id}/dependencies`),
])
const newCreatedVersions = this.$computeVersions(versions, this.members)

156
pages/auth.vue Normal file
View File

@ -0,0 +1,156 @@
<template>
<div>
<NuxtPage class="auth-container universal-card" :route="route" />
</div>
</template>
<script setup>
const route = useRoute()
</script>
<style lang="scss">
.auth-container {
width: 25rem;
padding: var(--gap-xl);
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
margin: 2rem auto;
h1 {
margin: 0;
color: var(--color-contrast);
}
h2 {
font-size: 1.25rem;
font-weight: 500;
margin: 0;
color: var(--color-contrast);
}
p {
margin: 0;
}
.btn {
font-weight: 700;
min-height: 2.5rem;
text-decoration: none;
}
input {
width: 100%;
border: none;
outline: none;
}
.btn.right-icon svg {
margin-left: var(--gap-sm);
}
.btn.left-icon svg {
margin-right: var(--gap-sm);
}
.input-group {
display: flex;
gap: var(--gap-md);
flex-wrap: wrap;
}
button.checkbox {
appearance: none !important;
border: none;
}
.continue-btn {
margin-left: auto;
margin-right: auto;
margin-block-start: 0;
}
.continue-btn svg {
margin: 0 0 0 0.5rem;
}
// login styles
.third-party {
display: grid;
gap: var(--gap-md);
grid-template-columns: repeat(2, 1fr);
width: 100%;
}
.third-party .btn {
width: 100%;
justify-content: center;
vertical-align: middle;
}
.third-party .btn svg {
margin-right: var(--gap-sm);
width: 1.25rem;
height: 1.25rem;
}
.discord-btn {
color: #ffffff;
background-color: #5865f2;
}
.apple-btn {
color: var(--color-accent-contrast);
background-color: var(--color-contrast);
}
.google-btn {
color: #ffffff;
background-color: #4285f4;
}
.gitlab-btn {
color: #ffffff;
background-color: #fc6d26;
}
.github-btn {
color: #ffffff;
background-color: #8740f1;
}
.microsoft-btn {
color: var(--color-accent-contrast);
background-color: var(--color-contrast);
}
.text-divider {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.text-divider div {
height: 2px;
width: 100%;
max-width: 5rem;
opacity: 40%;
border-radius: var(--radius-max);
background-color: var(--color-base);
}
.text-divider span {
margin-inline: var(--gap-sm);
}
@media screen and (max-width: 25.5rem) {
.third-party .btn {
grid-column: 1 / 3;
}
}
}
.auth-page-container {
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<div class="auth-page-container">
<h1>Reset your password</h1>
<template v-if="step === 'choose_method'">
<p>
Enter your email below and we'll send a recovery link to allow you to recover your account.
<NuxtTurnstile ref="turnstile" v-model="token" class="turnstile" />
</p>
<label for="email" hidden>Email or username</label>
<input id="email" v-model="email" type="text" placeholder="Email or username" />
<button class="btn btn-primary continue-btn" @click="recovery">Send recovery email</button>
</template>
<template v-else-if="step === 'passed_challenge'">
<p>Enter your new password below to gain access to your account.</p>
<label for="password" hidden>Password</label>
<input id="password" v-model="newPassword" type="password" placeholder="Password" />
<label for="confirm-password" hi2dden>Password</label>
<input
id="confirm-password"
v-model="confirmNewPassword"
type="password"
placeholder="Confirm password"
/>
<button class="btn btn-primary continue-btn" @click="changePassword">Reset password</button>
</template>
</div>
</template>
<script setup>
useHead({
title: 'Reset Password - Modrinth',
})
const auth = await useAuth()
if (auth.value.user) {
await navigateTo('/dashboard')
}
const data = useNuxtApp()
const route = useRoute()
const step = ref('choose_method')
if (route.query.flow) {
step.value = 'passed_challenge'
}
const turnstile = ref()
const email = ref('')
const token = ref('')
async function recovery() {
startLoading()
try {
await useBaseFetch('auth/password/reset', {
method: 'POST',
body: {
username: email.value,
challenge: token.value,
},
})
data.$notify({
group: 'main',
title: 'Email sent',
text: 'An email with instructions has been sent to you if the email was previously saved on your account.',
type: 'success',
})
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
turnstile.value?.reset()
}
stopLoading()
}
const newPassword = ref('')
const confirmNewPassword = ref('')
async function changePassword() {
startLoading()
try {
await useBaseFetch('auth/password', {
method: 'PATCH',
body: {
new_password: newPassword.value,
flow: route.query.flow,
},
})
data.$notify({
group: 'main',
title: 'Password successfully reset',
text: 'You can now log-in into your account with your new password.',
type: 'success',
})
await navigateTo('/auth/sign-in')
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
turnstile.value?.reset()
}
stopLoading()
}
</script>

195
pages/auth/sign-in.vue Normal file
View File

@ -0,0 +1,195 @@
<template>
<div class="auth-page-container">
<template v-if="flow">
<label for="two-factor-code">
<span class="label__title">Enter two-factor code</span>
<span class="label__description">Please enter a two-factor code to proceed.</span>
</label>
<input
id="two-factor-code"
v-model="twoFactorCode"
maxlength="11"
type="text"
placeholder="Enter code..."
/>
<button class="btn btn-primary continue-btn" @click="loginTwoFactor">
Sign in <RightArrowIcon />
</button>
</template>
<template v-else>
<h1>Continue with</h1>
<div class="third-party">
<a class="btn discord-btn" :href="getAuthUrl('discord')">
<DiscordIcon /> <span>Discord</span>
</a>
<a class="btn github-btn" :href="getAuthUrl('github')"
><GitHubIcon /> <span>GitHub</span></a
>
<a class="btn microsoft-btn" :href="getAuthUrl('microsoft')">
<MicrosoftIcon /> <span>Microsoft</span>
</a>
<a class="btn google-btn" :href="getAuthUrl('google')">
<GoogleIcon /> <span>Google</span>
</a>
<a class="btn apple-btn" :href="getAuthUrl('steam')"><SteamIcon /> <span>Steam</span></a>
<a class="btn gitlab-btn" :href="getAuthUrl('gitlab')">
<GitLabIcon /> <span>GitLab</span></a
>
</div>
<div class="text-divider">
<div></div>
<span>or</span>
<div></div>
</div>
<label for="email" hidden>Email or username</label>
<input id="email" v-model="email" type="text" placeholder="Email or username" />
<label for="password" hidden>Password</label>
<input id="password" v-model="password" type="password" placeholder="Password" />
<div class="account-options">
<NuxtTurnstile ref="turnstile" v-model="token" class="turnstile" />
<nuxt-link class="text-link" to="/auth/reset-password">Forgot password?</nuxt-link>
</div>
<button class="btn btn-primary continue-btn" @click="loginPassword()">
Continue <RightArrowIcon />
</button>
<p>
Don't have an account yet?
<nuxt-link
class="text-link"
:to="`/auth/sign-up${route.query.redirect ? `?redirect=${route.query.redirect}` : ''}`"
>
Create one.
</nuxt-link>
</p>
</template>
</div>
</template>
<script setup>
import { GitHubIcon, RightArrowIcon } from 'omorphia'
import DiscordIcon from 'assets/images/utils/discord.svg'
import GoogleIcon from 'assets/images/utils/google.svg'
import SteamIcon from 'assets/images/utils/steam.svg'
import MicrosoftIcon from 'assets/images/utils/microsoft.svg'
import GitLabIcon from 'assets/images/utils/gitlab.svg'
useHead({
title: 'Sign In - Modrinth',
})
const auth = await useAuth()
const route = useRoute()
if (route.fullPath.includes('new_account=true')) {
await navigateTo(
`/auth/welcome?authToken=${route.query.code}${
route.query.redirect ? `&redirect=${encodeURIComponent(route.query.redirect)}` : ''
}`
)
} else if (route.query.code) {
await loginHandler()
}
if (auth.value.user) {
await navigateTo('/dashboard')
}
const data = useNuxtApp()
const turnstile = ref()
const email = ref('')
const password = ref('')
const token = ref('')
const flow = ref(route.query.flow)
async function loginPassword() {
startLoading()
try {
const res = await useBaseFetch('auth/login', {
method: 'POST',
body: {
username: email.value,
password: password.value,
challenge: token.value,
},
})
if (res.flow) {
flow.value = res.flow
} else {
await loginHandler(res.session)
}
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
turnstile.value?.reset()
}
stopLoading()
}
const twoFactorCode = ref(null)
async function loginTwoFactor() {
startLoading()
try {
const res = await useBaseFetch('auth/login/2fa', {
method: 'POST',
body: {
flow: flow.value,
code: twoFactorCode.value ? twoFactorCode.value.toString() : twoFactorCode.value,
},
})
await loginHandler(res.session)
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
turnstile.value?.reset()
}
stopLoading()
}
async function loginHandler(token) {
if (token) {
await useAuth(token)
await useUser()
}
if (route.query.redirect) {
await navigateTo(route.query.redirect)
} else {
await navigateTo('/dashboard')
}
}
</script>
<style lang="scss" scoped>
.totp {
justify-content: center;
}
.totp-codes {
justify-content: center;
display: grid;
gap: var(--gap-md);
grid-template-columns: repeat(2, 1fr);
width: 100%;
}
.account-options {
display: flex;
width: 100%;
margin-block-start: 0 !important;
}
.account-options a {
margin-left: auto;
}
</style>

144
pages/auth/sign-up.vue Normal file
View File

@ -0,0 +1,144 @@
<template>
<div class="auth-page-container">
<h1>Create your account</h1>
<div class="third-party">
<a class="btn discord-btn" :href="getAuthUrl('discord')">
<DiscordIcon /> <span>Discord</span>
</a>
<a class="btn github-btn" :href="getAuthUrl('github')"><GitHubIcon /> <span>GitHub</span></a>
<a class="btn microsoft-btn" :href="getAuthUrl('microsoft')">
<MicrosoftIcon /> <span>Microsoft</span>
</a>
<a class="btn google-btn" :href="getAuthUrl('google')">
<GoogleIcon /> <span>Google</span>
</a>
<a class="btn apple-btn" :href="getAuthUrl('steam')"><SteamIcon /> <span>Steam</span></a>
<a class="btn gitlab-btn" :href="getAuthUrl('gitlab')"> <GitLabIcon /> <span>GitLab</span></a>
</div>
<div class="text-divider">
<div></div>
<span>or</span>
<div></div>
</div>
<label for="email" hidden>Email</label>
<input id="email" v-model="email" type="text" placeholder="Email" />
<label for="username" hidden>Username</label>
<input id="username" v-model="username" type="text" placeholder="Username" />
<label for="password" hidden>Password</label>
<input id="password" v-model="password" type="password" placeholder="Password" />
<label for="confirm-password" hidden>Password</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
placeholder="Confirm password"
/>
<Checkbox v-model="subscribe" class="subscribe-btn" label="Subscribe updates about Modrinth" />
<p>
By creating an account, you agree to Modrinth's
<nuxt-link to="/legal/terms" class="text-link">terms</nuxt-link> and
<nuxt-link to="/legal/privacy" class="text-link">privacy policy</nuxt-link>.
</p>
<button class="btn btn-primary continue-btn" @click="createAccount">
Create account <RightArrowIcon />
</button>
<p>
Already have an account yet?
<nuxt-link
class="text-link"
:to="`/auth/sign-in${route.query.redirect ? `?redirect=${route.query.redirect}` : ''}`"
>
Sign in.
</nuxt-link>
<NuxtTurnstile ref="turnstile" v-model="token" class="turnstile" />
</p>
</div>
</template>
<script setup>
import { GitHubIcon, RightArrowIcon, Checkbox } from 'omorphia'
import DiscordIcon from 'assets/images/utils/discord.svg'
import GoogleIcon from 'assets/images/utils/google.svg'
import SteamIcon from 'assets/images/utils/steam.svg'
import MicrosoftIcon from 'assets/images/utils/microsoft.svg'
import GitLabIcon from 'assets/images/utils/gitlab.svg'
useHead({
title: 'Sign Up - Modrinth',
})
const auth = await useAuth()
const route = useRoute()
if (route.fullPath.includes('new_account=true')) {
await navigateTo(
`/auth/welcome?authToken=${route.query.code}${
route.query.redirect ? `&redirect=${encodeURIComponent(route.query.redirect)}` : ''
}`
)
}
if (auth.value.user) {
await navigateTo('/dashboard')
}
const data = useNuxtApp()
const turnstile = ref()
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const token = ref('')
const subscribe = ref(true)
async function createAccount() {
startLoading()
try {
if (confirmPassword.value !== password.value) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: 'Passwords do not match!',
type: 'error',
})
turnstile.value?.reset()
}
const res = await useBaseFetch('auth/create', {
method: 'POST',
body: {
username: username.value,
password: password.value,
email: email.value,
challenge: token.value,
sign_up_newsletter: subscribe.value,
},
})
await useAuth(res.session)
await useUser()
if (route.query.redirect) {
await navigateTo(route.query.redirect)
} else {
await navigateTo('/dashboard')
}
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
turnstile.value?.reset()
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.subscribe-btn {
margin-block-start: 0 !important;
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<div class="auth-page-container">
<template v-if="auth.user && auth.user.email_verified && !success">
<h1>Email already verified</h1>
<p>Your email is already verified!</p>
<nuxt-link class="btn" link="/settings/account">
<SettingsIcon /> Account settings
</nuxt-link>
</template>
<template v-else-if="success">
<h1>Email verification</h1>
<p>Your email address has been successfully verified!</p>
<nuxt-link v-if="auth.user" class="btn" to="/settings/account">
<SettingsIcon /> Account settings
</nuxt-link>
<nuxt-link v-else to="/auth/sign-in" class="btn btn-primary continue-btn">
Sign in <RightArrowIcon />
</nuxt-link>
</template>
<template v-else>
<h1>Email verification failed</h1>
<p>
We were unable to verify your email.
<template v-if="auth.user">
Try re-sending the verification email through the button below.
</template>
<template v-else>
Try re-sending the verification email through your dashboard by signing in.
</template>
</p>
<button v-if="auth.user" class="btn btn-primary continue-btn" @click="resendVerifyEmail">
Resend verification email <RightArrowIcon />
</button>
<nuxt-link v-else to="/auth/sign-in" class="btn btn-primary continue-btn">
Sign in <RightArrowIcon />
</nuxt-link>
</template>
</div>
</template>
<script setup>
import { SettingsIcon, RightArrowIcon } from 'omorphia'
useHead({
title: 'Verify Email - Modrinth',
})
const auth = await useAuth()
const success = ref(false)
const route = useRoute()
if (route.query.flow) {
try {
const emailVerified = useState('emailVerified', () => null)
if (emailVerified.value === null) {
await useBaseFetch('auth/email/verify', {
method: 'POST',
body: {
flow: route.query.flow,
},
})
emailVerified.value = true
success.value = true
}
if (emailVerified.value) {
success.value = true
if (auth.value.token) {
await useAuth(auth.value.token)
}
}
} catch (err) {
success.value = false
}
}
</script>

46
pages/auth/welcome.vue Normal file
View File

@ -0,0 +1,46 @@
<template>
<div class="auth-page-container">
<h1>Welcome to Modrinth!</h1>
<p>
Thank you for creating an account. You can now follow and create projects, receive updates
about your favorite projects, and more!
</p>
<Checkbox v-model="subscribe" class="subscribe-btn" label="Subscribe updates about Modrinth" />
<button class="btn btn-primary continue-btn" @click="continueSignUp">Continue</button>
<p>
By creating an account, you agree to Modrinth's
<nuxt-link to="/legal/terms" class="text-link">terms</nuxt-link> and
<nuxt-link to="/legal/privacy" class="text-link">privacy policy</nuxt-link>.
</p>
</div>
</template>
<script setup>
import { Checkbox } from 'omorphia'
useHead({
title: 'Welcome - Modrinth',
})
const subscribe = ref(true)
async function continueSignUp() {
const route = useRoute()
await useAuth(route.query.authToken)
await useUser()
if (subscribe.value) {
try {
await useBaseFetch('auth/email/subscribe', {
method: 'POST',
})
} catch {}
}
if (route.query.redirect) {
await navigateTo(route.query.redirect)
} else {
await navigateTo('/dashboard')
}
}
</script>

View File

@ -1,12 +1,12 @@
<template>
<div class="dashboard-overview">
<section class="universal-card dashboard-header">
<Avatar :src="$auth.user.avatar_url" size="md" circle :alt="$auth.user.username" />
<Avatar :src="auth.user.avatar_url" size="md" circle :alt="auth.user.username" />
<div class="username">
<h1>
{{ $auth.user.username }}
{{ auth.user.username }}
</h1>
<NuxtLink class="goto-link" :to="`/user/${$auth.user.username}`">
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
Visit your profile
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
</NuxtLink>
@ -26,6 +26,7 @@
v-model:notifications="allNotifs"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
compact
/>
@ -116,14 +117,13 @@ useHead({
})
const auth = await useAuth()
const app = useNuxtApp()
const [{ data: projects }, { data: payouts }] = await Promise.all([
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
useBaseFetch(`user/${auth.value.user.id}/projects`, app.$defaultHeaders())
useBaseFetch(`user/${auth.value.user.id}/projects`)
),
useAsyncData(`user/${auth.value.user.id}/payouts`, () =>
useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders())
useBaseFetch(`user/${auth.value.user.id}/payouts`)
),
])

View File

@ -36,6 +36,7 @@
v-model:notifications="allNotifs"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
/>
</template>
@ -55,6 +56,8 @@ useHead({
title: 'Notifications - Modrinth',
})
const auth = await useAuth()
const route = useRoute()
const router = useRouter()

View File

@ -459,7 +459,6 @@ export default defineNuxtComponent({
{
method: 'PATCH',
body: baseData,
...this.$defaultHeaders(),
}
)

View File

@ -1,5 +1,6 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:breadcrumbs-stack="[{ href: '/dashboard/reports', label: 'Active reports' }]"
/>
@ -8,6 +9,7 @@
import ReportView from '~/components/ui/report/ReportView.vue'
const route = useRoute()
const auth = await useAuth()
useHead({
title: `Report ${route.params.id} - Modrinth`,

View File

@ -2,13 +2,14 @@
<div>
<section class="universal-card">
<h2>Reports you've filed</h2>
<ReportsList />
<ReportsList :auth="auth" />
</section>
</div>
</template>
<script setup>
import ReportsList from '~/components/ui/report/ReportsList.vue'
const auth = await useAuth()
useHead({
title: 'Active reports - Modrinth',
})

View File

@ -39,10 +39,9 @@ useHead({
})
const auth = await useAuth()
const app = useNuxtApp()
const { data: payouts } = await useAsyncData(`user/${auth.value.user.id}/payouts`, () =>
useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders())
useBaseFetch(`user/${auth.value.user.id}/payouts`)
)
</script>
<style lang="scss" scoped>

View File

@ -7,7 +7,7 @@
<div class="animate-strong">
<span>
<strong
v-for="projectType in $tag.projectTypes"
v-for="projectType in tags.projectTypes"
:key="projectType.id"
class="main-header-strong"
>
@ -23,14 +23,14 @@
</h2>
<div class="button-group">
<nuxt-link to="/mods" class="iconified-button brand-button"> Discover mods </nuxt-link>
<a
v-if="!$auth.user"
:href="getAuthUrl()"
<nuxt-link
v-if="!auth.user"
to="sign-up"
class="iconified-button outline-button"
rel="noopener nofollow"
>
Sign up
</a>
</nuxt-link>
<nuxt-link v-else to="/dashboard/projects" class="iconified-button outline-button">
Go to dashboard
</nuxt-link>
@ -530,6 +530,9 @@ import homepageProjects from '~/generated/homepage.json'
const searchQuery = ref('better')
const sortType = ref('relevance')
const auth = await useAuth()
const tags = useTags()
const [{ data: searchProjects, refresh: updateSearchProjects }, { data: notifications }] =
await Promise.all([
useAsyncData(
@ -1277,9 +1280,8 @@ const rows = shallowRef([
font-size: 1.625rem;
}
padding: 12rem 1rem;
// Magic number to cover header (space in rem header occupies)
margin-top: -5.75rem;
margin-top: -4rem;
padding: 11.25rem 1rem 12rem;
}
.users-section-outer {

View File

@ -38,9 +38,5 @@ useHead({
title: 'Staff overview - Modrinth',
})
const app = useNuxtApp()
const { data: stats } = await useAsyncData('statistics', () =>
useBaseFetch('statistics', app.$defaultHeaders())
)
const { data: stats } = await useAsyncData('statistics', () => useBaseFetch('statistics'))
</script>

View File

@ -8,6 +8,7 @@
:key="thread.id"
:thread="thread"
:link="getLink(thread)"
:auth="auth"
/>
</section>
</div>
@ -19,11 +20,8 @@ useHead({
title: 'Moderation inbox - Modrinth',
})
const app = useNuxtApp()
const { data: inbox } = await useAsyncData('thread/inbox', () =>
useBaseFetch('thread/inbox', app.$defaultHeaders())
)
const auth = await useAuth()
const { data: inbox } = await useAsyncData('thread/inbox', () => useBaseFetch('thread/inbox'))
function getLink(thread) {
if (thread.report_id) {

View File

@ -1,5 +1,6 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
/>
@ -7,6 +8,7 @@
<script setup>
import ReportView from '~/components/ui/report/ReportView.vue'
const auth = await useAuth()
const route = useRoute()
useHead({

View File

@ -2,13 +2,14 @@
<div>
<section class="universal-card">
<h2>Reports</h2>
<ReportsList moderation />
<ReportsList :auth="auth" moderation />
</section>
</div>
</template>
<script setup>
import ReportsList from '~/components/ui/report/ReportsList.vue'
const auth = await useAuth()
useHead({
title: 'Reports - Modrinth',
})

View File

@ -103,7 +103,7 @@ const TIME_24H = 86400000
const TIME_48H = TIME_24H * 2
const { data: projects } = await useAsyncData('moderation/projects?count=1000', () =>
useBaseFetch('moderation/projects?count=1000', app.$defaultHeaders())
useBaseFetch('moderation/projects?count=1000')
)
const members = ref([])
const projectType = ref('all')
@ -145,37 +145,30 @@ const projectTypes = computed(() => {
if (projects.value) {
const teamIds = projects.value.map((x) => x.team)
await useAsyncData(
'teams?ids=' + JSON.stringify(teamIds),
() => useBaseFetch('teams?ids=' + JSON.stringify(teamIds), app.$defaultHeaders()),
{
transform: (result) => {
if (result) {
members.value = result
const url = `teams?ids=${encodeURIComponent(JSON.stringify(teamIds))}`
const { data: result } = await useAsyncData(url, () => useBaseFetch(url))
projects.value = projects.value.map((project) => {
project.owner = members.value
.flat()
.find((x) => x.team_id === project.team && x.role === 'Owner').user
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE
project.age_warning = ''
if (project.age > TIME_24H * 2) {
project.age_warning = 'danger'
} else if (project.age > TIME_24H) {
project.age_warning = 'warning'
}
project.inferred_project_type = app.$getProjectTypeForUrl(
project.project_type,
project.loaders
)
return project
})
}
if (result.value) {
members.value = result.value
return result
},
}
)
projects.value = projects.value.map((project) => {
project.owner = members.value
.flat()
.find((x) => x.team_id === project.team && x.role === 'Owner').user
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE
project.age_warning = ''
if (project.age > TIME_24H * 2) {
project.age_warning = 'danger'
} else if (project.age > TIME_24H) {
project.age_warning = 'warning'
}
project.inferred_project_type = app.$getProjectTypeForUrl(
project.project_type,
project.loaders
)
return project
})
}
}
</script>
<style lang="scss" scoped>

View File

@ -3,7 +3,7 @@
:class="{
'search-page': true,
'normal-page': true,
'alt-layout': $cosmetics.searchLayout,
'alt-layout': cosmetics.searchLayout,
}"
>
<Head>
@ -65,7 +65,7 @@
>
<h3
v-if="
$tag.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
tags.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
.length > 0
"
class="sidebar-menu-heading"
@ -73,7 +73,7 @@
Loaders
</h3>
<SearchFilter
v-for="loader in $tag.loaders.filter((x) => {
v-for="loader in tags.loaders.filter((x) => {
if (
projectType.id === 'mod' &&
!showAllLoaders &&
@ -83,11 +83,11 @@
) {
return false
} else if (projectType.id === 'mod' && showAllLoaders) {
return $tag.loaderData.modLoaders.includes(x.name)
return tags.loaderData.modLoaders.includes(x.name)
} else if (projectType.id === 'plugin') {
return $tag.loaderData.pluginLoaders.includes(x.name)
return tags.loaderData.pluginLoaders.includes(x.name)
} else if (projectType.id === 'datapack') {
return $tag.loaderData.dataPackLoaders.includes(x.name)
return tags.loaderData.dataPackLoaders.includes(x.name)
} else {
return x.supported_project_types.includes(projectType.actual)
}
@ -113,7 +113,7 @@
<section v-if="projectType.id === 'plugin'" aria-label="Platform loader filters">
<h3
v-if="
$tag.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
tags.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
.length > 0
"
class="sidebar-menu-heading"
@ -121,8 +121,8 @@
Proxies
</h3>
<SearchFilter
v-for="loader in $tag.loaders.filter((x) =>
$tag.loaderData.pluginPlatformLoaders.includes(x.name)
v-for="loader in tags.loaders.filter((x) =>
tags.loaderData.pluginPlatformLoaders.includes(x.name)
)"
:key="loader.name"
ref="platformFilters"
@ -167,8 +167,8 @@
v-model="selectedVersions"
:options="
showSnapshots
? $tag.gameVersions.map((x) => x.version)
: $tag.gameVersions
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
"
@ -195,7 +195,7 @@
</aside>
<section class="normal-page__content">
<div
v-if="projectType.id === 'modpack' && $orElse($cosmetics.modpacksAlphaNotice, true)"
v-if="projectType.id === 'modpack' && $orElse(cosmetics.modpacksAlphaNotice, true)"
class="card information"
aria-label="Information"
>
@ -267,7 +267,7 @@
v-model="maxResults"
placeholder="Select one"
class="labeled-control__control"
:options="maxResultsForView[$cosmetics.searchDisplayMode[projectType.id]]"
:options="maxResultsForView[cosmetics.searchDisplayMode[projectType.id]]"
:searchable="false"
:close-on-select="true"
:show-labels="false"
@ -276,13 +276,13 @@
/>
</div>
<button
v-tooltip="$capitalizeString($cosmetics.searchDisplayMode[projectType.id]) + ' view'"
:aria-label="$capitalizeString($cosmetics.searchDisplayMode[projectType.id]) + ' view'"
v-tooltip="$capitalizeString(cosmetics.searchDisplayMode[projectType.id]) + ' view'"
:aria-label="$capitalizeString(cosmetics.searchDisplayMode[projectType.id]) + ' view'"
class="square-button"
@click="cycleSearchDisplayMode()"
>
<GridIcon v-if="$cosmetics.searchDisplayMode[projectType.id] === 'grid'" />
<ImageIcon v-else-if="$cosmetics.searchDisplayMode[projectType.id] === 'gallery'" />
<GridIcon v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'" />
<ImageIcon v-else-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'" />
<ListIcon v-else />
</button>
</div>
@ -302,7 +302,7 @@
<div
id="search-results"
class="project-list"
:class="'display-mode--' + $cosmetics.searchDisplayMode[projectType.id]"
:class="'display-mode--' + cosmetics.searchDisplayMode[projectType.id]"
role="list"
aria-label="Search results"
>
@ -310,7 +310,7 @@
v-for="result in results?.hits"
:id="result.slug ? result.slug : result.project_id"
:key="result.project_id"
:display="$cosmetics.searchDisplayMode[projectType.id]"
:display="cosmetics.searchDisplayMode[projectType.id]"
:featured-image="result.featured_gallery ? result.featured_gallery : result.gallery[0]"
:type="result.project_type"
:author="result.author"
@ -367,6 +367,9 @@ const showAllLoaders = ref(false)
const data = useNuxtApp()
const route = useRoute()
const cosmetics = useCosmetics()
const tags = useTags()
const query = ref('')
const facets = ref([])
const orFacets = ref([])
@ -444,7 +447,7 @@ if (route.query.o) {
currentPage.value = Math.ceil(route.query.o / maxResults.value) + 1
}
projectType.value = data.$tag.projectTypes.find(
projectType.value = tags.value.projectTypes.find(
(x) => x.id === route.path.substring(1, route.path.length - 1)
)
@ -481,15 +484,15 @@ const {
formattedFacets.push(orFacets.value)
} else if (projectType.value.id === 'plugin') {
formattedFacets.push(
data.$tag.loaderData.allPluginLoaders.map((x) => `categories:'${encodeURIComponent(x)}'`)
tags.value.loaderData.allPluginLoaders.map((x) => `categories:'${encodeURIComponent(x)}'`)
)
} else if (projectType.value.id === 'mod') {
formattedFacets.push(
data.$tag.loaderData.modLoaders.map((x) => `categories:'${encodeURIComponent(x)}'`)
tags.value.loaderData.modLoaders.map((x) => `categories:'${encodeURIComponent(x)}'`)
)
} else if (projectType.value.id === 'datapack') {
formattedFacets.push(
data.$tag.loaderData.dataPackLoaders.map((x) => `categories:'${encodeURIComponent(x)}'`)
tags.value.loaderData.dataPackLoaders.map((x) => `categories:'${encodeURIComponent(x)}'`)
)
}
@ -644,7 +647,7 @@ function getSearchUrl(offset, useObj) {
const categoriesMap = computed(() => {
const categories = {}
for (const category of data.$sortedCategories) {
for (const category of data.$sortedCategories()) {
if (categories[category.header]) {
categories[category.header].push(category)
} else {
@ -747,9 +750,9 @@ function onSearchChangeToTop(newPageNumber) {
}
function cycleSearchDisplayMode() {
data.$cosmetics.searchDisplayMode[projectType.value.id] = data.$cycleValue(
data.$cosmetics.searchDisplayMode[projectType.value.id],
data.$tag.projectViewModes
cosmetics.value.searchDisplayMode[projectType.value.id] = data.$cycleValue(
cosmetics.value.searchDisplayMode[projectType.value.id],
tags.value.projectViewModes
)
saveCosmetics()
setClosestMaxResults()
@ -775,7 +778,7 @@ function onMaxResultsChange(newPageNumber) {
}
function setClosestMaxResults() {
const view = data.$cosmetics.searchDisplayMode[projectType.value.id]
const view = cosmetics.value.searchDisplayMode[projectType.value.id]
const maxResultsOptions = maxResultsForView.value[view] ?? [20]
const currentMax = maxResults.value
if (!maxResultsOptions.includes(currentMax)) {

View File

@ -7,11 +7,17 @@
<NavStackItem link="/settings" label="Appearance">
<PaintbrushIcon />
</NavStackItem>
<template v-if="$auth.user">
<template v-if="auth.user">
<h3>User settings</h3>
<NavStackItem link="/settings/account" label="Account">
<UserIcon />
</NavStackItem>
<NavStackItem link="/settings/pats" label="PATs">
<KeyIcon />
</NavStackItem>
<NavStackItem link="/settings/sessions" label="Sessions">
<ShieldIcon />
</NavStackItem>
<NavStackItem link="/settings/monetization" label="Monetization">
<CurrencyIcon />
</NavStackItem>
@ -31,8 +37,11 @@ import NavStackItem from '~/components/ui/NavStackItem.vue'
import PaintbrushIcon from '~/assets/images/utils/paintbrush.svg'
import UserIcon from '~/assets/images/utils/user.svg'
import CurrencyIcon from '~/assets/images/utils/currency.svg'
import ShieldIcon from '~/assets/images/utils/shield.svg'
import KeyIcon from '~/assets/images/utils/key.svg'
const route = useRoute()
const auth = await useAuth()
</script>
<style lang="scss" scoped></style>

View File

@ -9,54 +9,259 @@
:has-to-type="true"
@proceed="deleteAccount"
/>
<Modal ref="modal_revoke_token" header="Revoke your Modrinth token">
<div class="modal-revoke-token markdown-body">
<p>
Revoking your Modrinth token can have unintended consequences. Please be aware that the
following could break:
</p>
<ul>
<li>Any application that uses your token to access the API.</li>
<li>Gradle - if Minotaur is given a incorrect token, your Gradle builds could fail.</li>
<li>
GitHub - if you use a GitHub action that uses the Modrinth API, it will cause errors.
</li>
</ul>
<p>If you are willing to continue, complete the following steps:</p>
<ol>
<li>
<a
href="https://github.com/settings/connections/applications/3acffb2e808d16d4b226"
target="_blank"
rel="noopener"
>
Head to the Modrinth Application page on GitHub.
</a>
Make sure to be logged into the GitHub account you used for Modrinth!
</li>
<li>Press the big red "Revoke Access" button next to the "Permissions" header.</li>
</ol>
<p>Once you have completed those steps, press the continue button below.</p>
<p>
<strong>
This will log you out of Modrinth, however, when you log back in, your token will be
regenerated.
</strong>
</p>
<div class="button-group">
<button class="iconified-button" @click="$refs.modal_revoke_token.hide()">
<CrossIcon />
<Modal ref="changeEmailModal" :header="`${auth.user.email ? 'Change' : 'Add'} email`">
<div class="universal-modal">
<p>Your account information is not displayed publicly.</p>
<label for="email-input"><span class="label__title">Email address</span> </label>
<input
id="email-input"
v-model="email"
maxlength="2048"
type="email"
:placeholder="`Enter your email address...`"
/>
<div class="input-group push-right">
<button class="iconified-button" @click="$refs.changeEmailModal.hide()">
<XIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="logout">
<RightArrowIcon />
Log out
<button
type="button"
class="iconified-button brand-button"
:disabled="!email"
@click="saveEmail()"
>
<SaveIcon />
Save email
</button>
</div>
</div>
</Modal>
<Modal
ref="managePasswordModal"
:header="`${
removePasswordMode ? 'Remove' : auth.user.has_password ? 'Change' : 'Add'
} password`"
>
<div class="universal-modal">
<ul v-if="newPassword !== confirmNewPassword" class="known-errors">
<li>Input passwords do not match!</li>
</ul>
<label v-if="removePasswordMode" for="old-password">
<span class="label__title">Confirm password</span>
<span class="label__description">Please enter your password to proceed.</span>
</label>
<label v-else-if="auth.user.has_password" for="old-password">
<span class="label__title">Old password</span>
</label>
<input
v-if="auth.user.has_password"
id="old-password"
v-model="oldPassword"
maxlength="2048"
type="password"
:placeholder="`${removePasswordMode ? 'Confirm' : 'Old'} password`"
/>
<template v-if="!removePasswordMode">
<label for="new-password"><span class="label__title">New password</span></label>
<input
id="new-password"
v-model="newPassword"
maxlength="2048"
type="password"
placeholder="New password"
/>
<label for="confirm-new-password"
><span class="label__title">Confirm new password</span></label
>
<input
id="confirm-new-password"
v-model="confirmNewPassword"
maxlength="2048"
type="password"
placeholder="Confirm new password"
/>
</template>
<p></p>
<div class="input-group push-right">
<button class="iconified-button" @click="$refs.managePasswordModal.hide()">
<XIcon />
Cancel
</button>
<template v-if="removePasswordMode">
<button type="button" class="iconified-button danger-button" @click="savePassword">
<TrashIcon />
Remove password
</button>
</template>
<template v-else>
<button
v-if="auth.user.has_password && auth.user.auth_providers.length > 0"
type="button"
class="iconified-button danger-button"
@click="removePasswordMode = true"
>
<TrashIcon />
Remove password
</button>
<button type="button" class="iconified-button brand-button" @click="savePassword">
<SaveIcon />
Save password
</button>
</template>
</div>
</div>
</Modal>
<Modal
ref="manageTwoFactorModal"
:header="`${
auth.user.has_totp && twoFactorStep === 0 ? 'Remove' : 'Setup'
} two-factor authentication`"
>
<div class="universal-modal">
<template v-if="auth.user.has_totp && twoFactorStep === 0">
<label for="two-factor-code">
<span class="label__title">Enter two-factor code</span>
<span class="label__description">Please enter a two-factor code to proceed.</span>
</label>
<input
id="two-factor-code"
v-model="twoFactorCode"
maxlength="11"
type="text"
placeholder="Enter code..."
/>
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
<div class="input-group push-right">
<button class="iconified-button" @click="$refs.manageTwoFactorModal.hide()">
<XIcon />
Cancel
</button>
<button class="iconified-button danger-button" @click="removeTwoFactor">
<TrashIcon />
Remove 2FA
</button>
</div>
</template>
<template v-else>
<template v-if="twoFactorStep === 0">
<p>
Two-factor authentication keeps your account secure by requiring access to a second
device in order to sign in.
<br /><br />
Scan the QR code with <a href="https://authy.com/">Authy</a>,
<a href="https://www.microsoft.com/en-us/security/mobile-authenticator-app">
Microsoft Authenticator</a
>, or any other 2FA app to begin.
</p>
<qrcode-vue
v-if="twoFactorSecret"
:value="`otpauth://totp/${encodeURIComponent(
auth.user.email
)}?secret=${twoFactorSecret}&issuer=Modrinth`"
:size="250"
:margin="2"
level="H"
/>
<p>
If the QR code does not scan, you can manually enter the secret:
<strong>{{ twoFactorSecret }}</strong>
</p>
</template>
<template v-if="twoFactorStep === 1">
<label for="verify-code">
<span class="label__title">Verify code</span>
<span class="label__description"
>Enter the one-time code from authenticator to verify access.
</span>
</label>
<input
id="verify-code"
v-model="twoFactorCode"
maxlength="6"
type="number"
placeholder="Enter code..."
/>
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
</template>
<template v-if="twoFactorStep === 2">
<p>
Download and save these back-up codes in a safe place. You can use these in-place of a
2FA code if you ever lose access to your device! You should protect these codes like
your password.
</p>
<p>Backup codes can only be used once.</p>
<ul>
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
</ul>
</template>
<div class="input-group push-right">
<button v-if="twoFactorStep === 1" class="iconified-button" @click="twoFactorStep = 0">
<LeftArrowIcon />
Back
</button>
<button
v-if="twoFactorStep !== 2"
class="iconified-button"
@click="$refs.manageTwoFactorModal.hide()"
>
<XIcon />
Cancel
</button>
<button
v-if="twoFactorStep <= 1"
class="iconified-button brand-button"
@click="twoFactorStep === 1 ? verifyTwoFactorCode() : (twoFactorStep = 1)"
>
<RightArrowIcon />
Continue
</button>
<button
v-if="twoFactorStep === 2"
class="iconified-button brand-button"
@click="$refs.manageTwoFactorModal.hide()"
>
<CheckIcon />
Complete setup
</button>
</div>
</template>
</div>
</Modal>
<Modal ref="manageProvidersModal" header="Authentication providers">
<div class="universal-modal">
<div class="table">
<div class="table-row table-head">
<div class="table-cell table-text">Provider</div>
<div class="table-cell table-text">Actions</div>
</div>
<div v-for="provider in authProviders" :key="provider.id" class="table-row">
<div class="table-cell table-text">
<span><component :is="provider.icon" /> {{ provider.display }}</span>
</div>
<div class="table-cell table-text manage">
<button
v-if="auth.user.auth_providers.includes(provider.id)"
class="btn"
@click="removeAuthProvider(provider.id)"
>
<TrashIcon /> Remove
</button>
<a v-else class="btn" :href="`${getAuthUrl(provider.id)}&token=${auth.token}`">
<ExternalIcon /> Add
</a>
</div>
</div>
</div>
<p></p>
<div class="input-group push-right">
<button class="iconified-button brand-button" @click="$refs.manageProvidersModal.hide()">
<CheckIcon />
Finish editing
</button>
</div>
</div>
</Modal>
<section class="universal-card">
<h2>User profile</h2>
<p>Visit your user profile to edit your profile information.</p>
@ -66,53 +271,83 @@
</section>
<section class="universal-card">
<h2>Account information</h2>
<p>Your account information is not displayed publicly.</p>
<ul class="known-errors">
<li v-if="hasMonetizationEnabled() && !email">
You must have an email address set since you are enrolled in the Creator Monetization
Program.
</li>
</ul>
<label for="email-input"><span class="label__title">Email address</span> </label>
<input
id="email-input"
v-model="email"
maxlength="2048"
type="email"
:placeholder="`Enter your email address...`"
/>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="hasMonetizationEnabled() && !email"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<h2>Account security</h2>
<section class="universal-card">
<h2>Authorization token</h2>
<p>
Your authorization token can be used with the Modrinth API, the Minotaur Gradle plugin, and
other applications that interact with Modrinth's API. Be sure to keep this secret!
</p>
<div class="input-group">
<button type="button" class="iconified-button" value="Copy to clipboard" @click="copyToken">
<template v-if="copied">
<CheckIcon />
Copied token to clipboard
</template>
<template v-else> <CopyIcon />Copy token to clipboard </template>
</button>
<button type="button" class="iconified-button" @click="$refs.modal_revoke_token.show()">
<SlashIcon />
Revoke token
</button>
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">Email</span>
<span class="label__description">Changes the email associated with your account.</span>
</label>
<div>
<button class="iconified-button" @click="$refs.changeEmailModal.show()">
<template v-if="auth.user.email">
<EditIcon />
Change email
</template>
<template v-else>
<PlusIcon />
Add email
</template>
</button>
</div>
</div>
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">Password</span>
<span v-if="auth.user.has_password" class="label__description">
Change <template v-if="auth.user.auth_providers.length > 0">or remove</template> the
password used to login to your account.
</span>
<span v-else class="label__description">
Set a permanent password to login to your account.
</span>
</label>
<div>
<button
class="iconified-button"
@click="
() => {
oldPassword = ''
newPassword = ''
confirmNewPassword = ''
removePasswordMode = false
$refs.managePasswordModal.show()
}
"
>
<KeyIcon />
<template v-if="auth.user.has_password"> Change password </template>
<template v-else> Add password </template>
</button>
</div>
</div>
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">Two-factor authentication</span>
<span class="label__description">
Add an additional layer of security to your account during login.
</span>
</label>
<div>
<button class="iconified-button" @click="showTwoFactorModal">
<template v-if="auth.user.has_totp"> <TrashIcon /> Remove 2FA </template>
<template v-else> <PlusIcon /> Setup 2FA </template>
</button>
</div>
</div>
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">Manage authentication providers</span>
<span class="label__description">
Add or remove sign-on methods from your account, including GitHub, GitLab, Microsoft,
Discord, Steam, and Google.
</span>
</label>
<div>
<button class="iconified-button" @click="$refs.manageProvidersModal.show()">
<SettingsIcon /> Manage providers
</button>
</div>
</div>
</section>
@ -134,128 +369,273 @@
</div>
</template>
<script>
<script setup>
import {
EditIcon,
UserIcon,
SaveIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
XIcon,
LeftArrowIcon,
RightArrowIcon,
CheckIcon,
GitHubIcon,
ExternalIcon,
} from 'omorphia'
import QrcodeVue from 'qrcode.vue'
import DiscordIcon from 'assets/images/utils/discord.svg'
import GoogleIcon from 'assets/images/utils/google.svg'
import SteamIcon from 'assets/images/utils/steam.svg'
import MicrosoftIcon from 'assets/images/utils/microsoft.svg'
import GitLabIcon from 'assets/images/utils/gitlab.svg'
import KeyIcon from '~/assets/images/utils/key.svg'
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
import Modal from '~/components/ui/Modal.vue'
import CrossIcon from '~/assets/images/utils/x.svg'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import UserIcon from '~/assets/images/utils/user.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import CopyIcon from '~/assets/images/utils/clipboard-copy.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import SlashIcon from '~/assets/images/utils/slash.svg'
useHead({
title: 'Account settings - Modrinth',
})
export default defineNuxtComponent({
components: {
Modal,
ModalConfirm,
CrossIcon,
RightArrowIcon,
CheckIcon,
SaveIcon,
UserIcon,
CopyIcon,
TrashIcon,
SlashIcon,
},
async setup() {
definePageMeta({
middleware: 'auth',
definePageMeta({
middleware: 'auth',
})
const data = useNuxtApp()
const auth = await useAuth()
const changeEmailModal = ref()
const email = ref(auth.value.user.email)
async function saveEmail() {
if (!email.value) {
return
}
startLoading()
try {
await useBaseFetch(`auth/email`, {
method: 'PATCH',
body: {
email: email.value,
},
})
changeEmailModal.value.hide()
await useAuth(auth.value.token)
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
const managePasswordModal = ref()
const removePasswordMode = ref(false)
const oldPassword = ref('')
const newPassword = ref('')
const confirmNewPassword = ref('')
async function savePassword() {
if (newPassword.value !== confirmNewPassword.value) {
return
}
startLoading()
try {
await useBaseFetch(`auth/password`, {
method: 'PATCH',
body: {
old_password: auth.value.user.has_password ? oldPassword.value : null,
new_password: removePasswordMode.value ? null : newPassword.value,
},
})
managePasswordModal.value.hide()
await useAuth(auth.value.token)
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
const manageTwoFactorModal = ref()
const twoFactorSecret = ref(null)
const twoFactorFlow = ref(null)
const twoFactorStep = ref(0)
async function showTwoFactorModal() {
twoFactorStep.value = 0
twoFactorCode.value = null
twoFactorIncorrect.value = false
if (auth.value.user.has_totp) {
manageTwoFactorModal.value.show()
return
}
twoFactorSecret.value = null
twoFactorFlow.value = null
backupCodes.value = []
manageTwoFactorModal.value.show()
startLoading()
try {
const res = await useBaseFetch('auth/2fa/get_secret', {
method: 'POST',
})
const auth = await useAuth()
twoFactorSecret.value = res.secret
twoFactorFlow.value = res.flow
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
return { auth }
const twoFactorIncorrect = ref(false)
const twoFactorCode = ref(null)
const backupCodes = ref([])
async function verifyTwoFactorCode() {
startLoading()
try {
const res = await useBaseFetch('auth/2fa', {
method: 'POST',
body: {
code: twoFactorCode.value ? twoFactorCode.value.toString() : '',
flow: twoFactorFlow.value,
},
})
backupCodes.value = res.backup_codes
twoFactorStep.value = 2
await useAuth(auth.value.token)
} catch (err) {
twoFactorIncorrect.value = true
}
stopLoading()
}
async function removeTwoFactor() {
startLoading()
try {
await useBaseFetch('auth/2fa', {
method: 'DELETE',
body: {
code: twoFactorCode.value ? twoFactorCode.value.toString() : '',
},
})
manageTwoFactorModal.value.hide()
await useAuth(auth.value.token)
} catch (err) {
twoFactorIncorrect.value = true
}
stopLoading()
}
const authProviders = [
{
id: 'github',
display: 'GitHub',
icon: GitHubIcon,
},
data() {
return {
copied: false,
email: this.auth.user.email,
showKnownErrors: false,
}
{
id: 'gitlab',
display: 'GitLab',
icon: GitLabIcon,
},
head: {
title: 'Account settings - Modrinth',
{
id: 'steam',
display: 'Steam',
icon: SteamIcon,
},
methods: {
async copyToken() {
this.copied = true
await navigator.clipboard.writeText(this.auth.token)
},
async deleteAccount() {
startLoading()
try {
await useBaseFetch(`user/${this.auth.user.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
useCookie('auth-token').value = null
alert('Please note that logging back in with GitHub will create a new account.')
window.location.href = '/'
stopLoading()
},
logout() {
this.$refs.modal_revoke_token.hide()
useCookie('auth-token').value = null
window.location.href = getAuthUrl()
},
hasMonetizationEnabled() {
return (
this.auth.user.payout_data.payout_wallet &&
this.auth.user.payout_data.payout_wallet_type &&
this.auth.user.payout_data.payout_address
)
},
async saveChanges() {
if (this.hasMonetizationEnabled() && !this.email) {
this.showKnownErrors = true
return
}
startLoading()
try {
const data = {
email: this.email ? this.email : null,
}
await useBaseFetch(`user/${this.auth.user.id}`, {
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
})
await useAuth(this.auth.token)
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
},
{
id: 'discord',
display: 'Discord',
icon: DiscordIcon,
},
})
{
id: 'microsoft',
display: 'Microsoft',
icon: MicrosoftIcon,
},
{
id: 'google',
display: 'Google',
icon: GoogleIcon,
},
]
async function removeAuthProvider(provider) {
startLoading()
try {
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
})
await useAuth(auth.value.token)
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
async function deleteAccount() {
startLoading()
try {
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'DELETE',
})
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
useCookie('auth-token').value = null
window.location.href = '/'
stopLoading()
}
</script>
<style lang="scss" scoped>
.modal-revoke-token {
padding: var(--spacing-card-bg);
canvas {
margin: 0 auto;
border-radius: var(--size-rounded-card);
}
.button-group {
width: fit-content;
margin-left: auto;
.table-row {
grid-template-columns: 1fr 10rem;
span {
display: flex;
align-items: center;
margin: auto 0;
svg {
width: 1.25rem;
height: 1.25rem;
margin-right: 0.35rem;
}
}
}
</style>

View File

@ -34,7 +34,7 @@
</label>
<input
id="search-layout-toggle"
v-model="$cosmetics.searchLayout"
v-model="cosmetics.searchLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
@ -49,7 +49,7 @@
</label>
<input
id="project-layout-toggle"
v-model="$cosmetics.projectLayout"
v-model="cosmetics.projectLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
@ -71,8 +71,8 @@
</label>
<Multiselect
:id="projectType + '-search-display-mode'"
v-model="$cosmetics.searchDisplayMode[projectType.id]"
:options="$tag.projectViewModes"
v-model="cosmetics.searchDisplayMode[projectType.id]"
:options="tags.projectViewModes"
:custom-label="$capitalizeString"
:searchable="false"
:close-on-select="true"
@ -94,7 +94,7 @@
</label>
<input
id="advanced-rendering"
v-model="$cosmetics.advancedRendering"
v-model="cosmetics.advancedRendering"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
@ -107,7 +107,7 @@
</label>
<input
id="modpacks-alpha-notice"
v-model="$cosmetics.modpacksAlphaNotice"
v-model="cosmetics.modpacksAlphaNotice"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
@ -124,7 +124,7 @@
</label>
<input
id="external-links-new-tab"
v-model="$cosmetics.externalLinksNewTab"
v-model="cosmetics.externalLinksNewTab"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
@ -141,9 +141,15 @@ export default defineNuxtComponent({
components: {
Multiselect,
},
setup() {
const cosmetics = useCosmetics()
const tags = useTags()
return { cosmetics, tags }
},
data() {
return {
searchDisplayMode: this.$cosmetics.searchDisplayMode,
searchDisplayMode: this.cosmetics.searchDisplayMode,
}
},
head: {
@ -151,7 +157,7 @@ export default defineNuxtComponent({
},
computed: {
listTypes() {
const types = this.$tag.projectTypes.map((type) => {
const types = this.tags.projectTypes.map((type) => {
return {
id: type.id,
name: this.$formatProjectType(type.id) + ' search',

View File

@ -184,7 +184,6 @@ export default defineNuxtComponent({
await useBaseFetch(`user/${this.auth.user.id}`, {
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
})
await useAuth(this.auth.token)

296
pages/settings/pats.vue Normal file
View File

@ -0,0 +1,296 @@
<template>
<div class="universal-card">
<Modal
ref="patModal"
:header="`${editPatIndex !== null ? 'Edit' : 'Create'} personal access token`"
>
<div class="universal-modal">
<label for="pat-name"><span class="label__title">Name</span> </label>
<input
id="pat-name"
v-model="name"
maxlength="2048"
type="email"
placeholder="Enter the PAT's name..."
/>
<label for="pat-scopes"><span class="label__title">Scopes</span> </label>
<div id="pat-scopes" class="checkboxes">
<Checkbox
v-for="(scope, index) in scopes"
:key="scope"
v-tooltip="
scope.startsWith('_')
? 'This scope is not allowed to be used with personal access tokens.'
: null
"
:disabled="scope.startsWith('_')"
:label="scope.replace('_', '')"
:model-value="(scopesVal & (1 << index)) === 1 << index"
@update:model-value="scopesVal ^= 1 << index"
/>
</div>
<label for="pat-name"><span class="label__title">Expires</span> </label>
<input id="pat-name" v-model="expires" type="date" />
<p></p>
<div class="input-group push-right">
<button class="iconified-button" @click="$refs.patModal.hide()">
<XIcon />
Cancel
</button>
<button
v-if="editPatIndex !== null"
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="editPat"
>
<SaveIcon />
Save changes
</button>
<button
v-else
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="createPat"
>
<PlusIcon />
Create PAT
</button>
</div>
</div>
</Modal>
<div class="header__row">
<div class="header__title">
<h2>Personal Access Tokens</h2>
</div>
<button
class="btn btn-primary"
@click="
() => {
name = null
scopesVal = 0
expires = null
editPatIndex = null
$refs.patModal.show()
}
"
>
<PlusIcon /> Create a PAT
</button>
</div>
<p>
PATs can be used to access Modrinth's API. For more information, see
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>. They
can be created and revoked at any time.
</p>
<div v-for="(pat, index) in pats" :key="pat.id" class="universal-card recessed token">
<div>
<div>
<strong>{{ pat.name }}</strong>
</div>
<div>
<template v-if="pat.access_token">
<CopyCode :text="pat.access_token" />
</template>
<template v-else>
<span
v-tooltip="
pat.last_used ? $dayjs(pat.last_login).format('MMMM D, YYYY [at] h:mm A') : null
"
>
<template v-if="pat.last_used">Last used {{ fromNow(pat.last_used) }}</template>
<template v-else>Never used</template>
</span>
<span v-tooltip="$dayjs(pat.expires).format('MMMM D, YYYY [at] h:mm A')">
Expires {{ fromNow(pat.expires) }}
</span>
<span v-tooltip="$dayjs(pat.created).format('MMMM D, YYYY [at] h:mm A')">
Created {{ fromNow(pat.created) }}
</span>
</template>
</div>
</div>
<div class="input-group">
<button
class="iconified-button raised-button"
@click="
() => {
editPatIndex = index
name = pat.name
scopesVal = pat.scopes
expires = $dayjs(pat.expires).format('YYYY-MM-DD')
$refs.patModal.show()
}
"
>
<EditIcon /> Edit token
</button>
<button class="iconified-button raised-button" @click="removePat(pat.id)">
<TrashIcon /> Revoke token
</button>
</div>
</div>
</div>
</template>
<script setup>
import { PlusIcon, Modal, XIcon, Checkbox, TrashIcon, EditIcon, SaveIcon } from 'omorphia'
import CopyCode from '~/components/ui/CopyCode.vue'
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'PATs - Modrinth',
})
const scopes = [
'Read user email',
'Read user data',
'Write user data',
'_Delete your account',
'_Write auth data',
'Read notifications',
'Write notifications',
'Read payouts',
'Write payouts',
'Read analytics',
'Create projects',
'Read projects',
'Write projects',
'Delete projects',
'Create versions',
'Read versions',
'Write versions',
'Delete versions',
'Create reports',
'Read reports',
'Write reports',
'Delete reports',
'Read threads',
'Write threads',
'_Create PATs',
'_Read PATs',
'_Write PATs',
'_Delete PATs',
'_Read sessions',
'_Delete sessions',
]
const data = useNuxtApp()
const patModal = ref()
const editPatIndex = ref(null)
const name = ref(null)
const scopesVal = ref(0)
const expires = ref(null)
const loading = ref(false)
const { data: pats, refresh } = await useAsyncData('pat', () => useBaseFetch('pat'))
async function createPat() {
startLoading()
loading.value = true
try {
const res = await useBaseFetch('pat', {
method: 'POST',
body: {
name: name.value,
scopes: scopesVal.value,
expires: data.$dayjs(expires.value).toISOString(),
},
})
pats.value.push(res)
patModal.value.hide()
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
loading.value = false
stopLoading()
}
async function editPat() {
startLoading()
loading.value = true
try {
await useBaseFetch(`pat/${pats.value[editPatIndex.value].id}`, {
method: 'PATCH',
body: {
name: name.value,
scopes: scopesVal.value,
expires: data.$dayjs(expires.value).toISOString(),
},
})
await refresh()
patModal.value.hide()
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
loading.value = false
stopLoading()
}
async function removePat(id) {
startLoading()
try {
pats.value = pats.value.filter((x) => x.id !== id)
await useBaseFetch(`pat/${id}`, {
method: 'DELETE',
})
await refresh()
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.checkboxes {
display: grid;
column-gap: 0.5rem;
@media screen and (min-width: 432px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 800px) {
grid-template-columns: repeat(3, 1fr);
}
}
.token {
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div class="universal-card">
<h2>Sessions</h2>
<p>
Here are all the devices that are currently logged in with your Modrinth account. You can log
out of each one individually.
<br /><br />
If you see an entry you don't recognize, log out of that device and change your Modrinth
account password immediately.
</p>
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session">
<div>
<div>
<strong>
{{ session.os ?? 'Unknown OS' }} {{ session.platform ?? 'Unknown platform' }}
{{ session.ip }}
</strong>
</div>
<div>
<template v-if="session.city">{{ session.city }}, {{ session.country }} </template>
<span v-tooltip="$dayjs(session.last_login).format('MMMM D, YYYY [at] h:mm A')">
Last accessed {{ fromNow(session.last_login) }}
</span>
<span v-tooltip="$dayjs(session.created).format('MMMM D, YYYY [at] h:mm A')">
Created {{ fromNow(session.created) }}
</span>
</div>
</div>
<div class="input-group">
<i v-if="session.current">Current session</i>
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
<XIcon /> Revoke session
</button>
</div>
</div>
</div>
</template>
<script setup>
import { XIcon } from 'omorphia'
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'Sessions - Modrinth',
})
const data = useNuxtApp()
const { data: sessions, refresh } = await useAsyncData('session/list', () =>
useBaseFetch('session/list')
)
async function revokeSession(id) {
startLoading()
try {
sessions.value = sessions.value.filter((x) => x.id !== id)
await useBaseFetch(`session/${id}`, {
method: 'DELETE',
})
await refresh()
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss">
.session {
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
}
</style>

View File

@ -46,7 +46,7 @@
<UploadIcon />
</FileInput>
<button
v-else-if="$auth.user && $auth.user.id === user.id"
v-else-if="auth.user && auth.user.id === user.id"
class="iconified-button"
@click="isEditing = true"
>
@ -54,7 +54,7 @@
Edit
</button>
<button
v-else-if="$auth.user"
v-else-if="auth.user"
class="iconified-button"
@click="$refs.modal_report.show()"
>
@ -81,7 +81,7 @@
@click="
() => {
isEditing = false
user = JSON.parse(JSON.stringify($auth.user))
user = JSON.parse(JSON.stringify(auth.user))
previewImage = null
icon = null
}
@ -96,7 +96,7 @@
</template>
<template v-else>
<div class="sidebar__item">
<Badge v-if="$tag.staffRoles.includes(user.role)" :type="user.role" />
<Badge v-if="tags.staffRoles.includes(user.role)" :type="user.role" />
<Badge v-else-if="projects.length > 0" type="creator" />
</div>
<span v-if="user.bio" class="sidebar__item bio">{{ user.bio }}</span>
@ -129,16 +129,6 @@
<UserIcon class="secondary-stat__icon" aria-hidden="true" />
<span class="secondary-stat__text"> User ID: <CopyCode :text="user.id" /> </span>
</div>
<a
v-if="githubUrl"
:href="githubUrl"
:target="$external()"
rel="noopener noreferrer nofollow"
class="sidebar__item github-button iconified-button"
>
<GitHubIcon aria-hidden="true" />
View GitHub profile
</a>
</template>
</div>
</div>
@ -161,7 +151,7 @@
/>
<div class="input-group">
<NuxtLink
v-if="$auth.user && $auth.user.id === user.id"
v-if="auth.user && auth.user.id === user.id"
class="iconified-button"
to="/dashboard/projects"
>
@ -169,13 +159,13 @@
Manage projects
</NuxtLink>
<button
v-tooltip="$capitalizeString($cosmetics.searchDisplayMode.user) + ' view'"
:aria-label="$capitalizeString($cosmetics.searchDisplayMode.user) + ' view'"
v-tooltip="$capitalizeString(cosmetics.searchDisplayMode.user) + ' view'"
:aria-label="$capitalizeString(cosmetics.searchDisplayMode.user) + ' view'"
class="square-button"
@click="cycleSearchDisplayMode()"
>
<GridIcon v-if="$cosmetics.searchDisplayMode.user === 'grid'" />
<ImageIcon v-else-if="$cosmetics.searchDisplayMode.user === 'gallery'" />
<GridIcon v-if="cosmetics.searchDisplayMode.user === 'grid'" />
<ImageIcon v-else-if="cosmetics.searchDisplayMode.user === 'gallery'" />
<ListIcon v-else />
</button>
</div>
@ -183,7 +173,7 @@
<div
v-if="projects.length > 0"
class="project-list"
:class="'display-mode--' + $cosmetics.searchDisplayMode.user"
:class="'display-mode--' + cosmetics.searchDisplayMode.user"
>
<ProjectCard
v-for="project in (route.params.projectType !== undefined
@ -199,7 +189,7 @@
:id="project.slug || project.id"
:key="project.id"
:name="project.title"
:display="$cosmetics.searchDisplayMode.user"
:display="cosmetics.searchDisplayMode.user"
:featured-image="
project.gallery
.slice()
@ -216,7 +206,7 @@
:client-side="project.client_side"
:server-side="project.server_side"
:status="
$auth.user && ($auth.user.id === user.id || $tag.staffRoles.includes($auth.user.role))
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
? project.status
: null
"
@ -226,7 +216,7 @@
</div>
<div v-else class="error">
<UpToDate class="icon" /><br />
<span v-if="$auth.user && $auth.user.id === user.id" class="text">
<span v-if="auth.user && auth.user.id === user.id" class="text">
You don't have any projects.<br />
Would you like to
<a class="link" @click.prevent="$refs.modal_creation.show()"> create one</a>?
@ -242,7 +232,6 @@ import ProjectCard from '~/components/ui/ProjectCard.vue'
import Badge from '~/components/ui/Badge.vue'
import Promotion from '~/components/ads/Promotion.vue'
import GitHubIcon from '~/assets/images/utils/github.svg'
import ReportIcon from '~/assets/images/utils/report.svg'
import SunriseIcon from '~/assets/images/utils/sunrise.svg'
import DownloadIcon from '~/assets/images/utils/download.svg'
@ -266,23 +255,25 @@ import Avatar from '~/components/ui/Avatar.vue'
const data = useNuxtApp()
const route = useRoute()
const auth = await useAuth()
const cosmetics = useCosmetics()
const tags = useTags()
let user, projects
try {
;[{ data: user }, { data: projects }] = await Promise.all([
useAsyncData(`user/${route.params.id}`, () =>
useBaseFetch(`user/${route.params.id}`, data.$defaultHeaders())
),
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
useAsyncData(
`user/${route.params.id}/projects`,
() => useBaseFetch(`user/${route.params.id}/projects`, data.$defaultHeaders()),
() => useBaseFetch(`user/${route.params.id}/projects`),
{
transform: (projects) => {
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
project.project_type = data.$getProjectTypeForUrl(
project.project_type,
project.categories
project.categories,
tags.value
)
}
@ -307,12 +298,6 @@ if (!user.value) {
})
}
let githubUrl
try {
const githubUser = await $fetch(`https://api.github.com/user/` + user.value.github_id)
githubUrl = ref(githubUser.html_url)
} catch {}
if (user.value.username !== route.params.id) {
await navigateTo(`/user/${user.value.username}`, { redirectCode: 301 })
}
@ -369,13 +354,12 @@ async function saveChanges() {
try {
if (icon.value) {
await useBaseFetch(
`user/${data.$auth.user.id}/icon?ext=${
`user/${auth.value.user.id}/icon?ext=${
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
}`,
{
method: 'PATCH',
body: icon.value,
...data.$defaultHeaders(),
}
)
}
@ -384,16 +368,15 @@ async function saveChanges() {
email: user.value.email,
bio: user.value.bio,
}
if (user.value.username !== data.$auth.user.username) {
if (user.value.username !== auth.value.user.username) {
reqData.username = user.value.username
}
await useBaseFetch(`user/${data.$auth.user.id}`, {
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body: reqData,
...data.$defaultHeaders(),
})
await useAuth(data.$auth.token)
await useAuth(auth.value.token)
isEditing.value = false
} catch (err) {
@ -409,9 +392,9 @@ async function saveChanges() {
}
function cycleSearchDisplayMode() {
data.$cosmetics.searchDisplayMode.user = data.$cycleValue(
data.$cosmetics.searchDisplayMode.user,
data.$tag.projectViewModes
cosmetics.value.searchDisplayMode.user = data.$cycleValue(
cosmetics.value.searchDisplayMode.user,
tags.value.projectViewModes
)
saveCosmetics()
}
@ -504,10 +487,6 @@ export default defineNuxtComponent({
cursor: default;
}
.github-button {
display: inline-flex;
}
.inputs {
margin-bottom: 1rem;

View File

@ -1,4 +1,6 @@
export default defineNuxtPlugin((nuxtApp) => {
export default defineNuxtPlugin(async (nuxtApp) => {
await useAuth()
await useUser()
const themeStore = useTheme()
nuxtApp.hook('app:mounted', () => {

View File

@ -1,11 +0,0 @@
export default defineNuxtPlugin(async (nuxtApp) => {
const authStore = await useAuth()
await useUser()
const cosmeticsStore = useCosmetics()
const tagsStore = useTags()
nuxtApp.provide('auth', authStore.value)
nuxtApp.provide('cosmetics', cosmeticsStore.value)
nuxtApp.provide('tag', tagsStore.value)
nuxtApp.provide('notify', (notif) => addNotification(notif))
})

View File

@ -1,29 +1,10 @@
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
export default defineNuxtPlugin((nuxtApp) => {
const tagStore = nuxtApp.$tag
const authStore = nuxtApp.$auth
nuxtApp.provide('defaultHeaders', () => {
const obj = { headers: {} }
if (process.server) {
const config = useRuntimeConfig()
if (config.rateLimitKey) {
obj.headers['x-ratelimit-key'] = config.rateLimitKey || ''
}
}
if (authStore.user) {
obj.headers.Authorization = authStore.token
}
return obj
})
nuxtApp.provide('formatNumber', formatNumber)
nuxtApp.provide('capitalizeString', capitalizeString)
nuxtApp.provide('formatMoney', formatMoney)
nuxtApp.provide('formatVersion', (versionsArray) => formatVersions(versionsArray, tagStore))
nuxtApp.provide('formatVersion', (versionsArray) => formatVersions(versionsArray))
nuxtApp.provide('orElse', (first, otherwise) => first ?? otherwise)
nuxtApp.provide('external', () => {
const cosmeticsStore = useCosmetics().value
@ -95,15 +76,17 @@ export default defineNuxtPlugin((nuxtApp) => {
.sort((a, b) => nuxtApp.$dayjs(b.date_published) - nuxtApp.$dayjs(a.date_published))
})
nuxtApp.provide('getProjectTypeForDisplay', (type, categories) => {
const tagStore = useTags()
if (type === 'mod') {
const isPlugin = categories.some((category) => {
return tagStore.loaderData.allPluginLoaders.includes(category)
return tagStore.value.loaderData.allPluginLoaders.includes(category)
})
const isMod = categories.some((category) => {
return tagStore.loaderData.modLoaders.includes(category)
return tagStore.value.loaderData.modLoaders.includes(category)
})
const isDataPack = categories.some((category) => {
return tagStore.loaderData.dataPackLoaders.includes(category)
return tagStore.value.loaderData.dataPackLoaders.includes(category)
})
if (isMod && isPlugin && isDataPack) {
@ -123,25 +106,29 @@ export default defineNuxtPlugin((nuxtApp) => {
return type
})
nuxtApp.provide('getProjectTypeForUrl', (type, loaders) =>
getProjectTypeForUrlShorthand(nuxtApp, type, loaders)
nuxtApp.provide('getProjectTypeForUrl', (type, loaders, tags) =>
getProjectTypeForUrlShorthand(type, loaders, tags)
)
nuxtApp.provide('cycleValue', cycleValue)
const sortedCategories = tagStore.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']
nuxtApp.provide('sortedCategories', () => {
const tagStore = useTags()
return x.indexOf(a.name) - x.indexOf(b.name)
}
return 0
return tagStore.value.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
})
})
nuxtApp.provide('sortedCategories', sortedCategories)
nuxtApp.provide('notify', (notif) => addNotification(notif))
})
export const formatNumber = (number, abbreviate = true) => {
const x = +number
@ -257,8 +244,9 @@ export const formatProjectStatus = (name) => {
return capitalizeString(name)
}
export const formatVersions = (versionArray, tag) => {
const allVersions = tag.gameVersions.slice().reverse()
export const formatVersions = (versionArray) => {
const tag = useTags()
const allVersions = tag.value.gameVersions.slice().reverse()
const allReleases = allVersions.filter((x) => x.version_type === 'release')
const intervals = []

25
pnpm-lock.yaml generated
View File

@ -34,6 +34,9 @@ dependencies:
omorphia:
specifier: ^0.4.31
version: 0.4.31
qrcode.vue:
specifier: ^3.4.0
version: 3.4.0(vue@3.3.4)
vue-multiselect:
specifier: ^3.0.0-alpha.2
version: 3.0.0-alpha.2
@ -48,6 +51,9 @@ devDependencies:
'@nuxtjs/eslint-config-typescript':
specifier: ^12.0.0
version: 12.0.0(eslint@8.41.0)(typescript@5.0.4)
'@nuxtjs/turnstile':
specifier: ^0.5.0
version: 0.5.0
'@types/node':
specifier: ^20.1.0
version: 20.1.0
@ -1357,6 +1363,17 @@ packages:
- supports-color
dev: true
/@nuxtjs/turnstile@0.5.0:
resolution: {integrity: sha512-EmEnYNDRavdmv9HXnInHVR5nSvjDG92s6pOrxd+ugqImkrnIQJttSd4DPq9UNhwUI/wLUL7z3VclVyQwNT2O7Q==}
dependencies:
'@nuxt/kit': 3.6.1
defu: 6.1.2
pathe: 1.1.1
transitivePeerDependencies:
- rollup
- supports-color
dev: true
/@pkgjs/parseargs@0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -6024,6 +6041,14 @@ packages:
engines: {node: '>=6'}
dev: true
/qrcode.vue@3.4.0(vue@3.3.4):
resolution: {integrity: sha512-4XeImbv10Fin16Fl2DArCMhGyAdvIg2jb7vDT+hZiIAMg/6H6mz9nUZr/dR8jBcun5VzNzkiwKhiqOGbloinwA==}
peerDependencies:
vue: ^3.0.0
dependencies:
vue: 3.3.4
dev: false
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true