refactor: migrate to common eslint+prettier configs (#4168)

* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
This commit is contained in:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,280 +1,280 @@
<template>
<div class="content">
<div class="mb-3 flex">
<VersionFilterControl
:versions="props.versions"
:game-versions="tags.gameVersions"
@update:query="updateQuery"
/>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="ml-auto mt-auto"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
<div class="card changelog-wrapper">
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="changelog-item"
>
<div
:class="`changelog-bar ${version.version_type} ${version.duplicate ? 'duplicate' : ''}`"
/>
<div class="version-wrapper">
<div class="version-header">
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
{{ version.name }}
</nuxt-link>
</h2>
<span v-if="version.author">
by
<nuxt-link class="text-link" :to="'/user/' + version.author.user.username">{{
version.author.user.username
}}</nuxt-link>
</span>
<span>
on
{{ $dayjs(version.date_published).format("MMM D, YYYY") }}</span
>
</div>
<a
:href="version.primaryFile.url"
class="iconified-button download"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
</div>
</div>
</div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="mb-2 flex justify-end"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
<div class="content">
<div class="mb-3 flex">
<VersionFilterControl
:versions="props.versions"
:game-versions="tags.gameVersions"
@update:query="updateQuery"
/>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="ml-auto mt-auto"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
<div class="card changelog-wrapper">
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="changelog-item"
>
<div
:class="`changelog-bar ${version.version_type} ${version.duplicate ? 'duplicate' : ''}`"
/>
<div class="version-wrapper">
<div class="version-header">
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
{{ version.name }}
</nuxt-link>
</h2>
<span v-if="version.author">
by
<nuxt-link class="text-link" :to="'/user/' + version.author.user.username">{{
version.author.user.username
}}</nuxt-link>
</span>
<span>
on
{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
>
</div>
<a
:href="version.primaryFile.url"
class="iconified-button download"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
</div>
</div>
</div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="mb-2 flex justify-end"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
</template>
<script setup>
import { Pagination } from "@modrinth/ui";
import { DownloadIcon } from "@modrinth/assets";
import { DownloadIcon } from '@modrinth/assets'
import { Pagination } from '@modrinth/ui'
import VersionFilterControl from '@modrinth/ui/src/components/version/VersionFilterControl.vue'
import VersionFilterControl from "@modrinth/ui/src/components/version/VersionFilterControl.vue";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { renderHighlightedString } from '~/helpers/highlight.js'
const props = defineProps({
project: {
type: Object,
default() {
return {};
},
},
versions: {
type: Array,
default() {
return [];
},
},
members: {
type: Array,
default() {
return [];
},
},
});
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
})
const title = `${props.project.title} - Changelog`;
const description = `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`;
const title = `${props.project.title} - Changelog`
const description = `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
});
title,
description,
ogTitle: title,
ogDescription: description,
})
const router = useNativeRouter();
const route = useNativeRoute();
const tags = useTags();
const router = useNativeRouter()
const route = useNativeRoute()
const tags = useTags()
const currentPage = ref(Number(route.query.page ?? 1));
const currentPage = ref(Number(route.query.page ?? 1))
const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.g) ?? [];
const selectedLoaders = getArrayOrString(route.query.l) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.c) ?? [];
const selectedGameVersions = getArrayOrString(route.query.g) ?? []
const selectedLoaders = getArrayOrString(route.query.l) ?? []
const selectedVersionTypes = getArrayOrString(route.query.c) ?? []
return props.versions.filter(
(projectVersion) =>
(selectedGameVersions.length === 0 ||
selectedGameVersions.some((gameVersion) =>
projectVersion.game_versions.includes(gameVersion),
)) &&
(selectedLoaders.length === 0 ||
selectedLoaders.some((loader) => projectVersion.loaders.includes(loader))) &&
(selectedVersionTypes.length === 0 ||
selectedVersionTypes.includes(projectVersion.version_type)),
);
});
return props.versions.filter(
(projectVersion) =>
(selectedGameVersions.length === 0 ||
selectedGameVersions.some((gameVersion) =>
projectVersion.game_versions.includes(gameVersion),
)) &&
(selectedLoaders.length === 0 ||
selectedLoaders.some((loader) => projectVersion.loaders.includes(loader))) &&
(selectedVersionTypes.length === 0 ||
selectedVersionTypes.includes(projectVersion.version_type)),
)
})
function switchPage(page) {
currentPage.value = page;
currentPage.value = page
router.replace({
query: {
...route.query,
page: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
router.replace({
query: {
...route.query,
page: currentPage.value !== 1 ? currentPage.value : undefined,
},
})
}
function updateQuery(newQueries) {
if (newQueries.page) {
currentPage.value = Number(newQueries.page);
} else if (newQueries.page === undefined) {
currentPage.value = 1;
}
if (newQueries.page) {
currentPage.value = Number(newQueries.page)
} else if (newQueries.page === undefined) {
currentPage.value = 1
}
router.replace({
query: {
...route.query,
...newQueries,
},
});
router.replace({
query: {
...route.query,
...newQueries,
},
})
}
</script>
<style lang="scss">
.changelog-wrapper {
padding-bottom: calc(var(--spacing-card-md) + 0.5rem);
padding-bottom: calc(var(--spacing-card-md) + 0.5rem);
}
.changelog-item {
display: block;
margin-bottom: 1rem;
position: relative;
padding-left: 1.8rem;
display: block;
margin-bottom: 1rem;
position: relative;
padding-left: 1.8rem;
&:last-child {
.changelog-bar.duplicate {
height: 100%;
background: transparent;
}
}
&:last-child {
.changelog-bar.duplicate {
height: 100%;
background: transparent;
}
}
.changelog-bar {
--color: var(--color-green);
.changelog-bar {
--color: var(--color-green);
&.alpha {
--color: var(--color-red);
}
&.alpha {
--color: var(--color-red);
}
&.release {
--color: var(--color-green);
}
&.release {
--color: var(--color-green);
}
&.beta {
--color: var(--color-orange);
}
&.beta {
--color: var(--color-orange);
}
left: 0;
top: 0.5rem;
width: 0.2rem;
min-width: 0.2rem;
position: absolute;
margin: 0 0.4rem;
border-radius: var(--size-rounded-max);
min-height: 100%;
background-color: var(--color);
left: 0;
top: 0.5rem;
width: 0.2rem;
min-width: 0.2rem;
position: absolute;
margin: 0 0.4rem;
border-radius: var(--size-rounded-max);
min-height: 100%;
background-color: var(--color);
&:before {
content: "";
width: 1rem;
height: 1rem;
position: absolute;
top: 0;
left: -0.4rem;
border-radius: var(--size-rounded-max);
background-color: var(--color);
}
&:before {
content: '';
width: 1rem;
height: 1rem;
position: absolute;
top: 0;
left: -0.4rem;
border-radius: var(--size-rounded-max);
background-color: var(--color);
}
&.duplicate {
background: linear-gradient(
to bottom,
transparent,
transparent 30%,
var(--color) 30%,
var(--color)
);
background-size: 100% 10px;
}
&.duplicate {
background: linear-gradient(
to bottom,
transparent,
transparent 30%,
var(--color) 30%,
var(--color)
);
background-size: 100% 10px;
}
&.duplicate {
height: calc(100% + 1.5rem);
}
}
&.duplicate {
height: calc(100% + 1.5rem);
}
}
.markdown-body {
margin: 0.5rem 0.5rem 0 0;
}
.markdown-body {
margin: 0.5rem 0.5rem 0 0;
}
.version-header {
display: flex;
align-items: center;
margin-top: 0.2rem;
.version-header {
display: flex;
align-items: center;
margin-top: 0.2rem;
.circle {
min-width: 0.75rem;
min-height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
}
.circle {
min-width: 0.75rem;
min-height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
}
.version-header-text {
display: flex;
align-items: baseline;
flex-wrap: wrap;
.version-header-text {
display: flex;
align-items: baseline;
flex-wrap: wrap;
h2 {
margin: 0;
font-size: var(--font-size-lg);
}
h2 {
margin: 0;
font-size: var(--font-size-lg);
}
h2,
span {
padding-right: 0.25rem;
}
}
h2,
span {
padding-right: 0.25rem;
}
}
.download {
margin-left: auto;
display: none;
.download {
margin-left: auto;
display: none;
@media screen and (min-width: 800px) {
display: flex;
}
}
}
@media screen and (min-width: 800px) {
display: flex;
}
}
}
}
.brand-button {
color: var(--color-accent-contrast);
color: var(--color-accent-contrast);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,38 @@
<template>
<section class="normal-page__content">
<div v-if="project.body" class="card">
<ProjectPageDescription :description="project.body" />
</div>
</section>
<section class="normal-page__content">
<div v-if="project.body" class="card">
<ProjectPageDescription :description="project.body" />
</div>
</section>
</template>
<script setup>
import { ProjectPageDescription } from "@modrinth/ui";
import { ProjectPageDescription } from '@modrinth/ui'
defineProps({
project: {
type: Object,
default() {
return {};
},
},
versions: {
type: Array,
default() {
return {};
},
},
members: {
type: Array,
default() {
return {};
},
},
organization: {
type: Object,
default() {
return {};
},
},
});
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return {}
},
},
members: {
type: Array,
default() {
return {}
},
},
organization: {
type: Object,
default() {
return {}
},
},
})
</script>

View File

@@ -1,222 +1,223 @@
<template>
<div>
<section class="universal-card">
<h2>Project status</h2>
<Badge :type="project.status" />
<p v-if="isApproved(project)">
Your project has been approved by the moderators and you may freely change project
visibility in
<router-link :to="`${getProjectLink(project)}/settings`" class="text-link"
>your project's settings</router-link
>.
</p>
<div v-else-if="isUnderReview(project)">
<p>
Modrinth's team of content moderators work hard to review all submitted projects.
Typically, you can expect a new project to be reviewed within 24 to 48 hours. Please keep
in mind that larger projects, especially modpacks, may require more time to review.
Certain holidays or events may also lead to delays depending on moderator availability.
Modrinth's moderators will leave a message below if they have any questions or concerns
for you.
</p>
<p>
If your review has taken more than 48 hours, check our
<a
class="text-link"
href="https://support.modrinth.com/en/articles/8793355-modrinth-project-review-times"
target="_blank"
>
support article on review times
</a>
for moderation delays.
</p>
</div>
<template v-else-if="isRejected(project)">
<p>
Your project does not currently meet Modrinth's
<nuxt-link to="/legal/rules" class="text-link" target="_blank">content rules</nuxt-link>
and the moderators have requested you make changes before it can be approved. Read the
messages from the moderators below and address their comments before resubmitting.
</p>
<p class="warning">
<IssuesIcon /> Repeated submissions without addressing the moderators' comments may result
in an account suspension.
</p>
</template>
<h3>Current visibility</h3>
<ul class="visibility-info">
<li v-if="isListed(project)">
<CheckIcon class="good" />
Listed in search results
</li>
<li v-else>
<XIcon class="bad" />
Not listed in search results
</li>
<li v-if="isListed(project)">
<CheckIcon class="good" />
Listed on the profiles of members
</li>
<li v-else>
<XIcon class="bad" />
Not listed on the profiles of members
</li>
<li v-if="isPrivate(project)">
<XIcon class="bad" />
Not accessible with a direct link
</li>
<li v-else>
<CheckIcon class="good" />
Accessible with a direct link
</li>
</ul>
</section>
<section id="messages" class="universal-card">
<h2>Messages</h2>
<p>
This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, please go to the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</p>
<p v-if="isApproved(project)" class="warning">
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
messages here if there is a problem with your project.
</p>
<ConversationThread
v-if="thread"
:thread="thread"
:project="project"
:set-status="setStatus"
:current-member="currentMember"
:auth="auth"
@update-thread="(newThread) => (thread = newThread)"
/>
</section>
</div>
<div>
<section class="universal-card">
<h2>Project status</h2>
<Badge :type="project.status" />
<p v-if="isApproved(project)">
Your project has been approved by the moderators and you may freely change project
visibility in
<router-link :to="`${getProjectLink(project)}/settings`" class="text-link"
>your project's settings</router-link
>.
</p>
<div v-else-if="isUnderReview(project)">
<p>
Modrinth's team of content moderators work hard to review all submitted projects.
Typically, you can expect a new project to be reviewed within 24 to 48 hours. Please keep
in mind that larger projects, especially modpacks, may require more time to review.
Certain holidays or events may also lead to delays depending on moderator availability.
Modrinth's moderators will leave a message below if they have any questions or concerns
for you.
</p>
<p>
If your review has taken more than 48 hours, check our
<a
class="text-link"
href="https://support.modrinth.com/en/articles/8793355-modrinth-project-review-times"
target="_blank"
>
support article on review times
</a>
for moderation delays.
</p>
</div>
<template v-else-if="isRejected(project)">
<p>
Your project does not currently meet Modrinth's
<nuxt-link to="/legal/rules" class="text-link" target="_blank">content rules</nuxt-link>
and the moderators have requested you make changes before it can be approved. Read the
messages from the moderators below and address their comments before resubmitting.
</p>
<p class="warning">
<IssuesIcon /> Repeated submissions without addressing the moderators' comments may result
in an account suspension.
</p>
</template>
<h3>Current visibility</h3>
<ul class="visibility-info">
<li v-if="isListed(project)">
<CheckIcon class="good" />
Listed in search results
</li>
<li v-else>
<XIcon class="bad" />
Not listed in search results
</li>
<li v-if="isListed(project)">
<CheckIcon class="good" />
Listed on the profiles of members
</li>
<li v-else>
<XIcon class="bad" />
Not listed on the profiles of members
</li>
<li v-if="isPrivate(project)">
<XIcon class="bad" />
Not accessible with a direct link
</li>
<li v-else>
<CheckIcon class="good" />
Accessible with a direct link
</li>
</ul>
</section>
<section id="messages" class="universal-card">
<h2>Messages</h2>
<p>
This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, please go to the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</p>
<p v-if="isApproved(project)" class="warning">
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
messages here if there is a problem with your project.
</p>
<ConversationThread
v-if="thread"
:thread="thread"
:project="project"
:set-status="setStatus"
:current-member="currentMember"
:auth="auth"
@update-thread="(newThread) => (thread = newThread)"
/>
</section>
</div>
</template>
<script setup>
import { CheckIcon, IssuesIcon, XIcon } from "@modrinth/assets";
import { Badge, injectNotificationManager } from "@modrinth/ui";
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
import { CheckIcon, IssuesIcon, XIcon } from '@modrinth/assets'
import { Badge, injectNotificationManager } from '@modrinth/ui'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import {
getProjectLink,
isApproved,
isListed,
isPrivate,
isRejected,
isUnderReview,
} from "~/helpers/projects.js";
getProjectLink,
isApproved,
isListed,
isPrivate,
isRejected,
isUnderReview,
} from '~/helpers/projects.js'
const { addNotification } = injectNotificationManager();
const { addNotification } = injectNotificationManager()
const props = defineProps({
project: {
type: Object,
default() {
return {};
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
});
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const auth = await useAuth();
const auth = await useAuth()
const { data: thread } = await useAsyncData(`thread/${props.project.thread_id}`, () =>
useBaseFetch(`thread/${props.project.thread_id}`),
);
useBaseFetch(`thread/${props.project.thread_id}`),
)
async function setStatus(status) {
startLoading();
startLoading()
try {
const data = {};
data.status = status;
await useBaseFetch(`project/${props.project.id}`, {
method: "PATCH",
body: data,
});
try {
const data = {}
data.status = status
await useBaseFetch(`project/${props.project.id}`, {
method: 'PATCH',
body: data,
})
const project = props.project;
project.status = status;
await props.resetProject();
thread.value = await useBaseFetch(`thread/${thread.value.id}`);
} catch (err) {
addNotification({
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
const project = props.project
project.status = status
await props.resetProject()
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading();
stopLoading()
}
</script>
<style lang="scss" scoped>
.stacked {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
.status-message {
:deep(.badge) {
display: contents;
:deep(.badge) {
display: contents;
svg {
vertical-align: top;
margin: 0;
}
}
svg {
vertical-align: top;
margin: 0;
}
}
p:last-child {
margin-bottom: 0;
}
p:last-child {
margin-bottom: 0;
}
}
.unavailable-error {
.code {
margin-top: var(--spacing-card-sm);
}
.code {
margin-top: var(--spacing-card-sm);
}
svg {
vertical-align: top;
}
svg {
vertical-align: top;
}
}
.visibility-info {
padding: 0;
list-style: none;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
}
li {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
}
}
svg {
&.good {
color: var(--color-green);
}
&.good {
color: var(--color-green);
}
&.bad {
color: var(--color-red);
}
&.bad {
color: var(--color-red);
}
}
.warning {
color: var(--color-orange);
font-weight: bold;
color: var(--color-orange);
font-weight: bold;
}
</style>

View File

@@ -1,34 +1,35 @@
<template>
<div>
<div class="universal-card">
<h2>Analytics</h2>
<div>
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your project, <strong>{{ project.title }}</strong
>. You can see the number of downloads, page views and revenue earned for your project, as
well as the total downloads and page views for {{ project.title }} by country.
</p>
</div>
<p>
This page shows you the analytics for your project,
<strong>{{ project.title }}</strong
>. You can see the number of downloads, page views and revenue earned for your project, as
well as the total downloads and page views for {{ project.title }} by country.
</p>
</div>
<ChartDisplay :projects="[props.project]" />
</div>
<ChartDisplay :projects="[props.project]" />
</div>
</template>
<script setup>
import ChartDisplay from "~/components/ui/charts/ChartDisplay.vue";
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const props = defineProps({
project: {
type: Object,
default() {
return {};
},
},
});
project: {
type: Object,
default() {
return {}
},
},
})
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
margin-bottom: var(--gap-md);
}
</style>

View File

@@ -1,106 +1,107 @@
<template>
<div>
<div class="universal-card">
<div class="markdown-disclaimer">
<h2>Description</h2>
<span class="label__description">
You can type an extended description of your mod here.
<span class="label__subdescription">
The description must clearly and honestly describe the purpose and function of the
project. See section 2.1 of the
<nuxt-link class="text-link" target="_blank" to="/legal/rules">Content Rules</nuxt-link>
for the full requirements.
</span>
</span>
</div>
<MarkdownEditor
v-model="description"
:disabled="
!currentMember ||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
TeamMemberPermission.EDIT_BODY
"
:on-image-upload="onUploadHandler"
/>
<div v-if="descriptionWarning" class="flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ descriptionWarning }}
</div>
<div class="input-group markdown-disclaimer">
<button
:disabled="!hasChanges"
class="iconified-button brand-button"
type="button"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</div>
</div>
<div>
<div class="universal-card">
<div class="markdown-disclaimer">
<h2>Description</h2>
<span class="label__description">
You can type an extended description of your mod here.
<span class="label__subdescription">
The description must clearly and honestly describe the purpose and function of the
project. See section 2.1 of the
<nuxt-link class="text-link" target="_blank" to="/legal/rules">Content Rules</nuxt-link>
for the full requirements.
</span>
</span>
</div>
<MarkdownEditor
v-model="description"
:disabled="
!currentMember ||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
TeamMemberPermission.EDIT_BODY
"
:on-image-upload="onUploadHandler"
/>
<div v-if="descriptionWarning" class="flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ descriptionWarning }}
</div>
<div class="input-group markdown-disclaimer">
<button
:disabled="!hasChanges"
class="iconified-button brand-button"
type="button"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import { countText, MIN_DESCRIPTION_CHARS } from "@modrinth/moderation";
import { MarkdownEditor } from "@modrinth/ui";
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
import { computed, ref } from "vue";
import { useImageUpload } from "~/composables/image-upload.ts";
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import { MarkdownEditor } from '@modrinth/ui'
import { type Project, type TeamMember, TeamMemberPermission } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useImageUpload } from '~/composables/image-upload.ts'
const props = defineProps<{
project: Project;
allMembers: TeamMember[];
currentMember: TeamMember | undefined;
patchProject: (payload: object, quiet?: boolean) => object;
}>();
project: Project
allMembers: TeamMember[]
currentMember: TeamMember | undefined
patchProject: (payload: object, quiet?: boolean) => object
}>()
const description = ref(props.project.body);
const description = ref(props.project.body)
const descriptionWarning = computed(() => {
const text = description.value?.trim() || "";
const charCount = countText(text);
const text = description.value?.trim() || ''
const charCount = countText(text)
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} readable characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`;
}
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} readable characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`
}
return null;
});
return null
})
const patchRequestPayload = computed(() => {
const payload: {
body?: string;
} = {};
const payload: {
body?: string
} = {}
if (description.value !== props.project.body) {
payload.body = description.value;
}
if (description.value !== props.project.body) {
payload.body = description.value
}
return payload;
});
return payload
})
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0;
});
return Object.keys(patchRequestPayload.value).length > 0
})
function saveChanges() {
props.patchProject(patchRequestPayload.value);
props.patchProject(patchRequestPayload.value)
}
async function onUploadHandler(file: File) {
const response = await useImageUpload(file, {
context: "project",
projectID: props.project.id,
});
const response = await useImageUpload(file, {
context: 'project',
projectID: props.project.id,
})
return response.url;
return response.url
}
</script>
<style scoped>
.markdown-disclaimer {
margin-block: 1rem;
margin-block: 1rem;
}
</style>

View File

@@ -1,465 +1,466 @@
<template>
<div>
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
:has-to-type="true"
:confirmation-text="project.title"
proceed-label="Delete"
@proceed="deleteProject"
/>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
</div>
</div>
<div>
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
:has-to-type="true"
:confirmation-text="project.title"
proceed-label="Delete"
@proceed="deleteProject"
/>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="project-name"
v-model="name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
/>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="project-name"
v-model="name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
/>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">
https://modrinth.com/{{ $getProjectTypeForUrl(project.project_type, project.loaders) }}/
</div>
<input
id="project-slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">
https://modrinth.com/{{ $getProjectTypeForUrl(project.project_type, project.loaders) }}/
</div>
<input
id="project-slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<template
v-if="
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<Multiselect
id="project-env-client"
v-model="clientSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Multiselect
id="project-env-server"
v-model="serverSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
</template>
<div class="adjacent-input">
<label for="project-visibility">
<span class="label__title">Visibility</span>
<div class="label__description">
Public and archived projects are visible in search. Unlisted projects are published, but
not visible in search or on user profiles. Private projects are only accessible by
members of the project.
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<template
v-if="
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<Multiselect
id="project-env-client"
v-model="clientSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Multiselect
id="project-env-server"
v-model="serverSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
</template>
<div class="adjacent-input">
<label for="project-visibility">
<span class="label__title">Visibility</span>
<div class="label__description">
Public and archived projects are visible in search. Unlisted projects are published, but
not visible in search or on user profiles. Private projects are only accessible by
members of the project.
<p>If approved by the moderators:</p>
<ul class="visibility-info">
<li>
<CheckIcon
v-if="visibility === 'approved' || visibility === 'archived'"
class="good"
/>
<XIcon v-else class="bad" />
{{ hasModifiedVisibility() ? "Will be v" : "V" }}isible in search
</li>
<li>
<XIcon v-if="visibility === 'unlisted' || visibility === 'private'" class="bad" />
<CheckIcon v-else class="good" />
{{ hasModifiedVisibility() ? "Will be v" : "V" }}isible on profile
</li>
<li>
<CheckIcon v-if="visibility !== 'private'" class="good" />
<IssuesIcon
v-else
v-tooltip="{
content:
visibility === 'private'
? 'Only members will be able to view the project.'
: '',
}"
class="warn"
/>
{{ hasModifiedVisibility() ? "Will be v" : "V" }}isible via URL
</li>
</ul>
</div>
</label>
<Multiselect
id="project-visibility"
v-model="visibility"
class="small-multiselect"
placeholder="Select one"
:options="tags.approvedStatuses"
:custom-label="(value) => formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon aria-hidden="true" />
Save changes
</button>
</div>
</section>
<p>If approved by the moderators:</p>
<ul class="visibility-info">
<li>
<CheckIcon
v-if="visibility === 'approved' || visibility === 'archived'"
class="good"
/>
<XIcon v-else class="bad" />
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible in search
</li>
<li>
<XIcon v-if="visibility === 'unlisted' || visibility === 'private'" class="bad" />
<CheckIcon v-else class="good" />
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible on profile
</li>
<li>
<CheckIcon v-if="visibility !== 'private'" class="good" />
<IssuesIcon
v-else
v-tooltip="{
content:
visibility === 'private'
? 'Only members will be able to view the project.'
: '',
}"
class="warn"
/>
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible via URL
</li>
</ul>
</div>
</label>
<Multiselect
id="project-visibility"
v-model="visibility"
class="small-multiselect"
placeholder="Select one"
:options="tags.approvedStatuses"
:custom-label="(value) => formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon aria-hidden="true" />
Save changes
</button>
</div>
</section>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete project</span>
</h3>
</div>
<p>
Removes your project from Modrinth's servers and search. Clicking on this will delete your
project, so be extra careful!
</p>
<button
type="button"
class="iconified-button danger-button"
:disabled="!hasDeletePermission"
@click="$refs.modal_confirm.show()"
>
<TrashIcon aria-hidden="true" />
Delete project
</button>
</section>
</div>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete project</span>
</h3>
</div>
<p>
Removes your project from Modrinth's servers and search. Clicking on this will delete your
project, so be extra careful!
</p>
<button
type="button"
class="iconified-button danger-button"
:disabled="!hasDeletePermission"
@click="$refs.modal_confirm.show()"
>
<TrashIcon aria-hidden="true" />
Delete project
</button>
</section>
</div>
</template>
<script setup>
import {
CheckIcon,
IssuesIcon,
SaveIcon,
TrashIcon,
UploadIcon,
XIcon,
TriangleAlertIcon,
} from "@modrinth/assets";
import { Avatar, ConfirmModal, injectNotificationManager } from "@modrinth/ui";
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
import { Multiselect } from "vue-multiselect";
import { MIN_SUMMARY_CHARS } from "@modrinth/moderation";
import FileInput from "~/components/ui/FileInput.vue";
CheckIcon,
IssuesIcon,
SaveIcon,
TrashIcon,
TriangleAlertIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import { Avatar, ConfirmModal, injectNotificationManager } from '@modrinth/ui'
import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
const { addNotification } = injectNotificationManager();
import FileInput from '~/components/ui/FileInput.vue'
const { addNotification } = injectNotificationManager()
const props = defineProps({
project: {
type: Object,
required: true,
default: () => ({}),
},
currentMember: {
type: Object,
required: true,
default: () => ({}),
},
patchProject: {
type: Function,
required: true,
default: () => {},
},
patchIcon: {
type: Function,
required: true,
default: () => {},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
});
project: {
type: Object,
required: true,
default: () => ({}),
},
currentMember: {
type: Object,
required: true,
default: () => ({}),
},
patchProject: {
type: Function,
required: true,
default: () => {},
},
patchIcon: {
type: Function,
required: true,
default: () => {},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const tags = useTags();
const router = useNativeRouter();
const tags = useTags()
const router = useNativeRouter()
const name = ref(props.project.title);
const slug = ref(props.project.slug);
const summary = ref(props.project.description);
const icon = ref(null);
const previewImage = ref(null);
const clientSide = ref(props.project.client_side);
const serverSide = ref(props.project.server_side);
const deletedIcon = ref(false);
const name = ref(props.project.title)
const slug = ref(props.project.slug)
const summary = ref(props.project.description)
const icon = ref(null)
const previewImage = ref(null)
const clientSide = ref(props.project.client_side)
const serverSide = ref(props.project.server_side)
const deletedIcon = ref(false)
const visibility = ref(
tags.value.approvedStatuses.includes(props.project.status)
? props.project.status
: props.project.requested_status,
);
tags.value.approvedStatuses.includes(props.project.status)
? props.project.status
: props.project.requested_status,
)
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2;
return (props.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS;
});
const EDIT_DETAILS = 1 << 2
return (props.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
})
const hasDeletePermission = computed(() => {
const DELETE_PROJECT = 1 << 7;
return (props.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT;
});
const DELETE_PROJECT = 1 << 7
return (props.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
})
const summaryWarning = computed(() => {
const text = summary.value?.trim() || "";
const charCount = text.length;
const text = summary.value?.trim() || ''
const charCount = text.length
if (charCount < MIN_SUMMARY_CHARS) {
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`;
}
if (charCount < MIN_SUMMARY_CHARS) {
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`
}
return null;
});
return null
})
const sideTypes = ["required", "optional", "unsupported"];
const sideTypes = ['required', 'optional', 'unsupported']
const patchData = computed(() => {
const data = {};
const data = {}
if (name.value !== props.project.title) {
data.title = name.value.trim();
}
if (slug.value !== props.project.slug) {
data.slug = slug.value.trim();
}
if (summary.value !== props.project.description) {
data.description = summary.value.trim();
}
if (clientSide.value !== props.project.client_side) {
data.client_side = clientSide.value;
}
if (serverSide.value !== props.project.server_side) {
data.server_side = serverSide.value;
}
if (tags.value.approvedStatuses.includes(props.project.status)) {
if (visibility.value !== props.project.status) {
data.status = visibility.value;
}
} else if (visibility.value !== props.project.requested_status) {
data.requested_status = visibility.value;
}
if (name.value !== props.project.title) {
data.title = name.value.trim()
}
if (slug.value !== props.project.slug) {
data.slug = slug.value.trim()
}
if (summary.value !== props.project.description) {
data.description = summary.value.trim()
}
if (clientSide.value !== props.project.client_side) {
data.client_side = clientSide.value
}
if (serverSide.value !== props.project.server_side) {
data.server_side = serverSide.value
}
if (tags.value.approvedStatuses.includes(props.project.status)) {
if (visibility.value !== props.project.status) {
data.status = visibility.value
}
} else if (visibility.value !== props.project.requested_status) {
data.requested_status = visibility.value
}
return data;
});
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value;
});
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const hasModifiedVisibility = () => {
const originalVisibility = tags.value.approvedStatuses.includes(props.project.status)
? props.project.status
: props.project.requested_status;
const originalVisibility = tags.value.approvedStatuses.includes(props.project.status)
? props.project.status
: props.project.requested_status
return originalVisibility !== visibility.value;
};
return originalVisibility !== visibility.value
}
const saveChanges = async () => {
if (hasChanges.value) {
await props.patchProject(patchData.value);
}
if (hasChanges.value) {
await props.patchProject(patchData.value)
}
if (deletedIcon.value) {
await deleteIcon();
deletedIcon.value = false;
} else if (icon.value) {
await props.patchIcon(icon.value);
icon.value = null;
}
};
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await props.patchIcon(icon.value)
icon.value = null
}
}
const showPreviewImage = (files) => {
const reader = new FileReader();
icon.value = files[0];
deletedIcon.value = false;
reader.readAsDataURL(icon.value);
reader.onload = (event) => {
previewImage.value = event.target.result;
};
};
const reader = new FileReader()
icon.value = files[0]
deletedIcon.value = false
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
const deleteProject = async () => {
await useBaseFetch(`project/${props.project.id}`, {
method: "DELETE",
});
await initUserProjects();
await router.push("/dashboard/projects");
addNotification({
title: "Project deleted",
text: "Your project has been deleted.",
type: "success",
});
};
await useBaseFetch(`project/${props.project.id}`, {
method: 'DELETE',
})
await initUserProjects()
await router.push('/dashboard/projects')
addNotification({
title: 'Project deleted',
text: 'Your project has been deleted.',
type: 'success',
})
}
const markIconForDeletion = () => {
deletedIcon.value = true;
icon.value = null;
previewImage.value = null;
};
deletedIcon.value = true
icon.value = null
previewImage.value = null
}
const deleteIcon = async () => {
await useBaseFetch(`project/${props.project.id}/icon`, {
method: "DELETE",
});
await props.resetProject();
addNotification({
title: "Project icon removed",
text: "Your project's icon has been removed.",
type: "success",
});
};
await useBaseFetch(`project/${props.project.id}/icon`, {
method: 'DELETE',
})
await props.resetProject()
addNotification({
title: 'Project icon removed',
text: "Your project's icon has been removed.",
type: 'success',
})
}
</script>
<style lang="scss" scoped>
.visibility-info {
padding: 0;
list-style: none;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
}
li {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
}
}
svg {
&.good {
color: var(--color-green);
}
&.good {
color: var(--color-green);
}
&.bad {
color: var(--color-red);
}
&.bad {
color: var(--color-red);
}
&.warn {
color: var(--color-orange);
}
&.warn {
color: var(--color-orange);
}
}
.summary-input {
min-height: 8rem;
max-width: 24rem;
min-height: 8rem;
max-width: 24rem;
}
.small-multiselect {
max-width: 15rem;
max-width: 15rem;
}
.button-group {
justify-content: flex-start;
justify-content: flex-start;
}
</style>

View File

@@ -1,261 +1,261 @@
<template>
<div>
<section class="universal-card">
<h2 class="label__title size-card-header">License</h2>
<p class="label__description">
It is important to choose a proper license for your
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
list or provide a custom license. You may also provide a custom URL to your chosen license;
otherwise, the license text will be displayed. See our
<nuxt-link
to="/news/article/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide
</nuxt-link>
for more information.
</p>
<div>
<section class="universal-card">
<h2 class="label__title size-card-header">License</h2>
<p class="label__description">
It is important to choose a proper license for your
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
list or provide a custom license. You may also provide a custom URL to your chosen license;
otherwise, the license text will be displayed. See our
<nuxt-link
to="/news/article/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide
</nuxt-link>
for more information.
</p>
<div class="adjacent-input">
<label for="license-multiselect">
<span class="label__title">Select a license</span>
<span class="label__description">
How users are and aren't allowed to use your project.
</span>
</label>
<div class="adjacent-input">
<label for="license-multiselect">
<span class="label__title">Select a license</span>
<span class="label__description">
How users are and aren't allowed to use your project.
</span>
</label>
<div class="w-1/2">
<DropdownSelect
v-model="license"
name="License selector"
:options="builtinLicenses"
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
placeholder="Select license..."
/>
</div>
</div>
<div class="w-1/2">
<DropdownSelect
v-model="license"
name="License selector"
:options="builtinLicenses"
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
placeholder="Select license..."
/>
</div>
</div>
<div class="adjacent-input" v-if="license.requiresOnlyOrLater">
<label for="or-later-checkbox">
<span class="label__title">Later editions</span>
<span class="label__description">
The license you selected has an "or later" clause. If you check this box, users may use
your project under later editions of the license.
</span>
</label>
<div v-if="license.requiresOnlyOrLater" class="adjacent-input">
<label for="or-later-checkbox">
<span class="label__title">Later editions</span>
<span class="label__description">
The license you selected has an "or later" clause. If you check this box, users may use
your project under later editions of the license.
</span>
</label>
<Checkbox
id="or-later-checkbox"
v-model="allowOrLater"
:disabled="!hasPermission"
description="Allow later editions"
class="w-1/2"
>
Allow later editions
</Checkbox>
</div>
<Checkbox
id="or-later-checkbox"
v-model="allowOrLater"
:disabled="!hasPermission"
description="Allow later editions"
class="w-1/2"
>
Allow later editions
</Checkbox>
</div>
<div class="adjacent-input">
<label for="license-url">
<span class="label__title">License URL</span>
<span class="label__description" v-if="license?.friendly !== 'Custom'">
The web location of the full license text. If you don't provide a link, the license text
will be displayed instead.
</span>
<span class="label__description" v-else>
The web location of the full license text. You have to provide a link since this is a
custom license.
</span>
</label>
<div class="adjacent-input">
<label for="license-url">
<span class="label__title">License URL</span>
<span v-if="license?.friendly !== 'Custom'" class="label__description">
The web location of the full license text. If you don't provide a link, the license text
will be displayed instead.
</span>
<span v-else class="label__description">
The web location of the full license text. You have to provide a link since this is a
custom license.
</span>
</label>
<div class="w-1/2">
<input
id="license-url"
v-model="licenseUrl"
type="url"
maxlength="2048"
:placeholder="license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
class="w-full"
/>
</div>
</div>
<div class="w-1/2">
<input
id="license-url"
v-model="licenseUrl"
type="url"
maxlength="2048"
:placeholder="license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
class="w-full"
/>
</div>
</div>
<div class="adjacent-input" v-if="license?.friendly === 'Custom'">
<label for="license-spdx" v-if="!nonSpdxLicense">
<span class="label__title">SPDX identifier</span>
<span class="label__description">
If your license does not have an offical
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>, check the box and enter the name of the license instead.
</span>
</label>
<label for="license-name" v-else>
<span class="label__title">License name</span>
<span class="label__description"
>The full name of the license. If the license has a SPDX identifier, please uncheck the
checkbox and use the identifier instead.</span
>
</label>
<div v-if="license?.friendly === 'Custom'" class="adjacent-input">
<label v-if="!nonSpdxLicense" for="license-spdx">
<span class="label__title">SPDX identifier</span>
<span class="label__description">
If your license does not have an offical
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>, check the box and enter the name of the license instead.
</span>
</label>
<label v-else for="license-name">
<span class="label__title">License name</span>
<span class="label__description"
>The full name of the license. If the license has a SPDX identifier, please uncheck the
checkbox and use the identifier instead.</span
>
</label>
<div class="input-stack w-1/2">
<input
v-if="!nonSpdxLicense"
v-model="license.short"
id="license-spdx"
class="w-full"
type="text"
maxlength="128"
placeholder="SPDX identifier"
:disabled="!hasPermission"
/>
<input
v-else
v-model="license.short"
id="license-name"
class="w-full"
type="text"
maxlength="128"
placeholder="License name"
:disabled="!hasPermission"
/>
<div class="input-stack w-1/2">
<input
v-if="!nonSpdxLicense"
id="license-spdx"
v-model="license.short"
class="w-full"
type="text"
maxlength="128"
placeholder="SPDX identifier"
:disabled="!hasPermission"
/>
<input
v-else
id="license-name"
v-model="license.short"
class="w-full"
type="text"
maxlength="128"
placeholder="License name"
:disabled="!hasPermission"
/>
<Checkbox
v-if="license?.friendly === 'Custom'"
v-model="nonSpdxLicense"
:disabled="!hasPermission"
description="License does not have a SPDX identifier"
>
License does not have a SPDX identifier
</Checkbox>
</div>
</div>
<Checkbox
v-if="license?.friendly === 'Custom'"
v-model="nonSpdxLicense"
:disabled="!hasPermission"
description="License does not have a SPDX identifier"
>
License does not have a SPDX identifier
</Checkbox>
</div>
</div>
<div class="input-stack">
<button
type="button"
class="iconified-button brand-button"
:disabled="
!hasChanges ||
!hasPermission ||
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
<div class="input-stack">
<button
type="button"
class="iconified-button brand-button"
:disabled="
!hasChanges ||
!hasPermission ||
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { Checkbox, DropdownSelect } from "@modrinth/ui";
import { SaveIcon } from "@modrinth/assets";
import { SaveIcon } from '@modrinth/assets'
import { Checkbox, DropdownSelect } from '@modrinth/ui'
import {
TeamMemberPermission,
builtinLicenses,
formatProjectType,
type BuiltinLicense,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed, ref, type Ref } from "vue";
type BuiltinLicense,
builtinLicenses,
formatProjectType,
type Project,
type TeamMember,
TeamMemberPermission,
} from '@modrinth/utils'
import { computed, type Ref, ref } from 'vue'
const props = defineProps<{
project: Project;
currentMember: TeamMember | undefined;
patchProject: (payload: Object, quiet?: boolean) => Object;
}>();
project: Project
currentMember: TeamMember | undefined
patchProject: (payload: object, quiet?: boolean) => object
}>()
const licenseUrl = ref(props.project.license.url);
const licenseUrl = ref(props.project.license.url)
const license: Ref<{
friendly: string;
short: string;
requiresOnlyOrLater?: boolean;
friendly: string
short: string
requiresOnlyOrLater?: boolean
}> = ref({
friendly: "",
short: "",
requiresOnlyOrLater: false,
});
friendly: '',
short: '',
requiresOnlyOrLater: false,
})
const allowOrLater = ref(props.project.license.id.includes("-or-later"));
const nonSpdxLicense = ref(props.project.license.id.includes("LicenseRef-"));
const allowOrLater = ref(props.project.license.id.includes('-or-later'))
const nonSpdxLicense = ref(props.project.license.id.includes('LicenseRef-'))
const oldLicenseId = props.project.license.id;
const oldLicenseId = props.project.license.id
const trimmedLicenseId = oldLicenseId
.replaceAll("-only", "")
.replaceAll("-or-later", "")
.replaceAll("LicenseRef-", "");
.replaceAll('-only', '')
.replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '')
license.value = builtinLicenses.find((x) => x.short === trimmedLicenseId) ?? {
friendly: "Custom",
short: oldLicenseId.replaceAll("LicenseRef-", ""),
requiresOnlyOrLater: oldLicenseId.includes("-or-later"),
};
friendly: 'Custom',
short: oldLicenseId.replaceAll('LicenseRef-', ''),
requiresOnlyOrLater: oldLicenseId.includes('-or-later'),
}
if (oldLicenseId === "LicenseRef-Unknown") {
// Mark it as not having a license, forcing the user to select one
license.value = {
friendly: "",
short: oldLicenseId.replaceAll("LicenseRef-", ""),
requiresOnlyOrLater: false,
};
if (oldLicenseId === 'LicenseRef-Unknown') {
// Mark it as not having a license, forcing the user to select one
license.value = {
friendly: '',
short: oldLicenseId.replaceAll('LicenseRef-', ''),
requiresOnlyOrLater: false,
}
}
const hasPermission = computed(() => {
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS;
});
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS
})
const licenseId = computed(() => {
let id = "";
let id = ''
if (
(nonSpdxLicense && license.value.friendly === "Custom") ||
license.value.short === "All-Rights-Reserved" ||
license.value.short === "Unknown"
) {
id += "LicenseRef-";
}
if (
(nonSpdxLicense.value && license.value.friendly === 'Custom') ||
license.value.short === 'All-Rights-Reserved' ||
license.value.short === 'Unknown'
) {
id += 'LicenseRef-'
}
id += license.value.short;
if (license.value.requiresOnlyOrLater) {
id += allowOrLater.value ? "-or-later" : "-only";
}
id += license.value.short
if (license.value.requiresOnlyOrLater) {
id += allowOrLater.value ? '-or-later' : '-only'
}
if (nonSpdxLicense && license.value.friendly === "Custom") {
id = id.replaceAll(" ", "-");
}
if (nonSpdxLicense.value && license.value.friendly === 'Custom') {
id = id.replaceAll(' ', '-')
}
return id;
});
return id
})
const patchRequestPayload = computed(() => {
const payload: {
license_id?: string;
license_url?: string | null; // null = remove url
} = {};
const payload: {
license_id?: string
license_url?: string | null // null = remove url
} = {}
if (licenseId.value !== props.project.license.id) {
payload.license_id = licenseId.value;
}
if (licenseId.value !== props.project.license.id) {
payload.license_id = licenseId.value
}
if (licenseUrl.value !== props.project.license.url) {
payload.license_url = licenseUrl.value ? licenseUrl.value : null;
}
if (licenseUrl.value !== props.project.license.url) {
payload.license_url = licenseUrl.value ? licenseUrl.value : null
}
return payload;
});
return payload
})
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0;
});
return Object.keys(patchRequestPayload.value).length > 0
})
function saveChanges() {
props.patchProject(patchRequestPayload.value);
props.patchProject(patchRequestPayload.value)
}
</script>

View File

@@ -1,372 +1,367 @@
<template>
<div>
<section class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label
id="project-issue-tracker"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker </span>
<span class="label__description">
A place for users to report bugs, issues, and concerns about your project.
</span>
</label>
<TriangleAlertIcon
v-if="isIssuesLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isIssuesDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isIssuesUrlCommon"
v-tooltip="`Link includes a domain which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-issue-tracker"
v-model="issuesUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-source-code"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code </span>
<span class="label__description">
A page/repository containing the source code for your project
</span>
</label>
<TriangleAlertIcon
v-if="isSourceLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isSourceDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isSourceUrlCommon"
v-tooltip="`Link includes a domain which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-source-code"
v-model="sourceUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-wiki-page"
title="A page containing information, documentation, and help for the project."
>
<span class="label__title">Wiki page</span>
<span class="label__description">
A page containing information, documentation, and help for the project.
</span>
</label>
<TriangleAlertIcon
v-if="isWikiLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isWikiDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-wiki-page"
v-model="wikiUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server.">
<span class="label__title">Discord invite </span>
<span class="label__description"> An invitation link to your Discord server. </span>
</label>
<TriangleAlertIcon
v-if="isDiscordLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isDiscordUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-discord-invite"
v-model="discordUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<span class="label">
<span class="label__title">Donation links</span>
<span class="label__description">
Add donation links for users to support you directly.
</span>
</span>
<div>
<section class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label
id="project-issue-tracker"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker </span>
<span class="label__description">
A place for users to report bugs, issues, and concerns about your project.
</span>
</label>
<TriangleAlertIcon
v-if="isIssuesLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isIssuesDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isIssuesUrlCommon"
v-tooltip="`Link includes a domain which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-issue-tracker"
v-model="issuesUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-source-code"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code </span>
<span class="label__description">
A page/repository containing the source code for your project
</span>
</label>
<TriangleAlertIcon
v-if="isSourceLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isSourceDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isSourceUrlCommon"
v-tooltip="`Link includes a domain which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-source-code"
v-model="sourceUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-wiki-page"
title="A page containing information, documentation, and help for the project."
>
<span class="label__title">Wiki page</span>
<span class="label__description">
A page containing information, documentation, and help for the project.
</span>
</label>
<TriangleAlertIcon
v-if="isWikiLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isWikiDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-wiki-page"
v-model="wikiUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server.">
<span class="label__title">Discord invite </span>
<span class="label__description"> An invitation link to your Discord server. </span>
</label>
<TriangleAlertIcon
v-if="isDiscordLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isDiscordUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-discord-invite"
v-model="discordUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<span class="label">
<span class="label__title">Donation links</span>
<span class="label__description">
Add donation links for users to support you directly.
</span>
</span>
<div
v-for="(donationLink, index) in donationLinks"
:key="`donation-link-${index}`"
class="input-group donation-link-group"
>
<input
v-model="donationLink.url"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
@input="updateDonationLinks"
/>
<DropdownSelect
v-model="donationLink.id"
name="Donation platform selector"
:options="tags.donationPlatforms.map((x) => x.short)"
:display-name="
(option) => tags.donationPlatforms.find((platform) => platform.short === option)?.name
"
placeholder="Select platform"
render-up
class="platform-selector"
@update:model-value="updateDonationLinks"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
<div
v-for="(donationLink, index) in donationLinks"
:key="`donation-link-${index}`"
class="input-group donation-link-group"
>
<input
v-model="donationLink.url"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
@input="updateDonationLinks"
/>
<DropdownSelect
v-model="donationLink.id"
name="Donation platform selector"
:options="tags.donationPlatforms.map((x) => x.short)"
:display-name="
(option) => tags.donationPlatforms.find((platform) => platform.short === option)?.name
"
placeholder="Select platform"
render-up
class="platform-selector"
@update:model-value="updateDonationLinks"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script setup>
import { DropdownSelect } from "@modrinth/ui";
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import {
isCommonUrl,
isDiscordUrl,
isLinkShortener,
commonLinkDomains,
} from "@modrinth/moderation";
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
import { DropdownSelect } from '@modrinth/ui'
const tags = useTags();
const tags = useTags()
const props = defineProps({
project: {
type: Object,
default() {
return {};
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
patchProject: {
type: Function,
default() {
return () => {};
},
},
});
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {}
},
},
})
const issuesUrl = ref(props.project.issues_url);
const sourceUrl = ref(props.project.source_url);
const wikiUrl = ref(props.project.wiki_url);
const discordUrl = ref(props.project.discord_url);
const issuesUrl = ref(props.project.issues_url)
const sourceUrl = ref(props.project.source_url)
const wikiUrl = ref(props.project.wiki_url)
const discordUrl = ref(props.project.discord_url)
const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true;
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues);
});
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues)
})
const isSourceUrlCommon = computed(() => {
if (!sourceUrl.value || sourceUrl.value.trim().length === 0) return true;
return isCommonUrl(sourceUrl.value, commonLinkDomains.source);
});
if (!sourceUrl.value || sourceUrl.value.trim().length === 0) return true
return isCommonUrl(sourceUrl.value, commonLinkDomains.source)
})
const isDiscordUrlCommon = computed(() => {
if (!discordUrl.value || discordUrl.value.trim().length === 0) return true;
return isCommonUrl(discordUrl.value, commonLinkDomains.discord);
});
if (!discordUrl.value || discordUrl.value.trim().length === 0) return true
return isCommonUrl(discordUrl.value, commonLinkDomains.discord)
})
const isIssuesDiscordUrl = computed(() => {
return isDiscordUrl(issuesUrl.value);
});
return isDiscordUrl(issuesUrl.value)
})
const isSourceDiscordUrl = computed(() => {
return isDiscordUrl(sourceUrl.value);
});
return isDiscordUrl(sourceUrl.value)
})
const isWikiDiscordUrl = computed(() => {
return isDiscordUrl(wikiUrl.value);
});
return isDiscordUrl(wikiUrl.value)
})
const isIssuesLinkShortener = computed(() => {
return isLinkShortener(issuesUrl.value);
});
return isLinkShortener(issuesUrl.value)
})
const isSourceLinkShortener = computed(() => {
return isLinkShortener(sourceUrl.value);
});
return isLinkShortener(sourceUrl.value)
})
const isWikiLinkShortener = computed(() => {
return isLinkShortener(wikiUrl.value);
});
return isLinkShortener(wikiUrl.value)
})
const isDiscordLinkShortener = computed(() => {
return isLinkShortener(discordUrl.value);
});
return isLinkShortener(discordUrl.value)
})
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls));
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls))
rawDonationLinks.push({
id: null,
platform: null,
url: null,
});
const donationLinks = ref(rawDonationLinks);
id: null,
platform: null,
url: null,
})
const donationLinks = ref(rawDonationLinks)
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2;
return (props.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS;
});
const EDIT_DETAILS = 1 << 2
return (props.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
})
const patchData = computed(() => {
const data = {};
const data = {}
if (checkDifference(issuesUrl.value, props.project.issues_url)) {
data.issues_url = issuesUrl.value === "" ? null : issuesUrl.value.trim();
}
if (checkDifference(sourceUrl.value, props.project.source_url)) {
data.source_url = sourceUrl.value === "" ? null : sourceUrl.value.trim();
}
if (checkDifference(wikiUrl.value, props.project.wiki_url)) {
data.wiki_url = wikiUrl.value === "" ? null : wikiUrl.value.trim();
}
if (checkDifference(discordUrl.value, props.project.discord_url)) {
data.discord_url = discordUrl.value === "" ? null : discordUrl.value.trim();
}
if (checkDifference(issuesUrl.value, props.project.issues_url)) {
data.issues_url = issuesUrl.value === '' ? null : issuesUrl.value.trim()
}
if (checkDifference(sourceUrl.value, props.project.source_url)) {
data.source_url = sourceUrl.value === '' ? null : sourceUrl.value.trim()
}
if (checkDifference(wikiUrl.value, props.project.wiki_url)) {
data.wiki_url = wikiUrl.value === '' ? null : wikiUrl.value.trim()
}
if (checkDifference(discordUrl.value, props.project.discord_url)) {
data.discord_url = discordUrl.value === '' ? null : discordUrl.value.trim()
}
const validDonationLinks = donationLinks.value.filter((link) => link.url && link.id);
const validDonationLinks = donationLinks.value.filter((link) => link.url && link.id)
if (
validDonationLinks !== props.project.donation_urls &&
!(
props.project.donation_urls &&
props.project.donation_urls.length === 0 &&
validDonationLinks.length === 0
)
) {
data.donation_urls = validDonationLinks;
}
if (
validDonationLinks !== props.project.donation_urls &&
!(
props.project.donation_urls &&
props.project.donation_urls.length === 0 &&
validDonationLinks.length === 0
)
) {
data.donation_urls = validDonationLinks
}
if (data.donation_urls) {
data.donation_urls.forEach((link) => {
const platform = tags.value.donationPlatforms.find((platform) => platform.short === link.id);
link.platform = platform.name;
});
}
if (data.donation_urls) {
data.donation_urls.forEach((link) => {
const platform = tags.value.donationPlatforms.find((platform) => platform.short === link.id)
link.platform = platform.name
})
}
return data;
});
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0;
});
return Object.keys(patchData.value).length > 0
})
async function saveChanges() {
if (patchData.value && (await props.patchProject(patchData.value))) {
donationLinks.value = JSON.parse(JSON.stringify(props.project.donation_urls));
donationLinks.value.push({
id: null,
platform: null,
url: null,
});
}
if (patchData.value && (await props.patchProject(patchData.value))) {
donationLinks.value = JSON.parse(JSON.stringify(props.project.donation_urls))
donationLinks.value.push({
id: null,
platform: null,
url: null,
})
}
}
function updateDonationLinks() {
const links = donationLinks.value;
links.forEach((link) => {
if (link.url) {
const url = link.url.toLowerCase();
if (url.includes("patreon.com")) {
link.id = "patreon";
} else if (url.includes("ko-fi.com")) {
link.id = "ko-fi";
} else if (url.includes("paypal.com") || url.includes("paypal.me")) {
link.id = "paypal";
} else if (url.includes("buymeacoffee.com") || url.includes("buymeacoff.ee")) {
link.id = "bmac";
} else if (url.includes("github.com/sponsors")) {
link.id = "github";
}
}
});
if (!links.find((link) => !(link.url && link.id))) {
links.push({
id: null,
platform: null,
url: null,
});
}
donationLinks.value = links;
const links = donationLinks.value
links.forEach((link) => {
if (link.url) {
const url = link.url.toLowerCase()
if (url.includes('patreon.com')) {
link.id = 'patreon'
} else if (url.includes('ko-fi.com')) {
link.id = 'ko-fi'
} else if (url.includes('paypal.com') || url.includes('paypal.me')) {
link.id = 'paypal'
} else if (url.includes('buymeacoffee.com') || url.includes('buymeacoff.ee')) {
link.id = 'bmac'
} else if (url.includes('github.com/sponsors')) {
link.id = 'github'
}
}
})
if (!links.find((link) => !(link.url && link.id))) {
links.push({
id: null,
platform: null,
url: null,
})
}
donationLinks.value = links
}
function checkDifference(newLink, existingLink) {
if (newLink === "" && existingLink !== null) {
return true;
}
if (!newLink && !existingLink) {
return false;
}
return newLink !== existingLink;
if (newLink === '' && existingLink !== null) {
return true
}
if (!newLink && !existingLink) {
return false
}
return newLink !== existingLink
}
</script>
<style lang="scss" scoped>
.donation-link-group {
input {
flex-grow: 2;
max-width: 26rem;
}
input {
flex-grow: 2;
max-width: 26rem;
}
:deep(.animated-dropdown .selected) {
height: 40px;
}
:deep(.animated-dropdown .selected) {
height: 40px;
}
}
.button-group {
justify-content: flex-start;
justify-content: flex-start;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,377 +1,378 @@
<template>
<div>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Tags</span>
</h3>
</div>
<div>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Tags</span>
</h3>
</div>
<div
v-if="tooManyTagsWarning && !allTagsSelectedWarning"
class="my-2 flex items-center gap-1.5 text-orange"
>
<TriangleAlertIcon class="my-auto" />
{{ tooManyTagsWarning }}
</div>
<div
v-if="tooManyTagsWarning && !allTagsSelectedWarning"
class="my-2 flex items-center gap-1.5 text-orange"
>
<TriangleAlertIcon class="my-auto" />
{{ tooManyTagsWarning }}
</div>
<div v-if="multipleResolutionTagsWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ multipleResolutionTagsWarning }}
</div>
<div v-if="multipleResolutionTagsWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ multipleResolutionTagsWarning }}
</div>
<div v-if="allTagsSelectedWarning" class="my-2 flex items-center gap-1.5 text-red">
<TriangleAlertIcon class="my-auto" />
<span>{{ allTagsSelectedWarning }}</span>
</div>
<div v-if="allTagsSelectedWarning" class="my-2 flex items-center gap-1.5 text-red">
<TriangleAlertIcon class="my-auto" />
<span>{{ allTagsSelectedWarning }}</span>
</div>
<p>
Accurate tagging is important to help people find your
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<p>
Accurate tagging is important to help people find your
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<p v-if="project.versions.length === 0" class="known-errors">
Please upload a version first in order to select tags!
</p>
<template v-else>
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div class="label">
<h4>
<span class="label__title">{{ formatCategoryHeader(header) }}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
Select all categories that reflect the themes or function of your
{{ formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'features'">
Select all of the features that your
{{ formatProjectType(project.project_type).toLowerCase() }} makes use of.
</template>
<template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your
{{ formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'performance impact'">
Select the realistic performance impact of your
{{ formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
{{ formatProjectType(project.project_type).toLowerCase() }} is configurable to
different levels of performance impact.
</template>
</span>
</div>
<div class="category-list input-div">
<Checkbox
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:model-value="selectedTags.includes(category)"
:description="formatCategory(category.name)"
class="category-selector"
@update:model-value="toggleCategory(category)"
>
<div class="category-selector__label">
<div
v-if="header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
</template>
<div class="label">
<h4>
<span class="label__title"><StarIcon /> Featured tags</span>
</h4>
<span class="label__description">
You can feature up to 3 of your most relevant tags. Other tags may be promoted to
featured if you do not select all 3.
</span>
</div>
<p v-if="selectedTags.length < 1">
Select at least one category in order to feature a category.
</p>
<div class="category-list input-div">
<Checkbox
v-for="category in selectedTags"
:key="`featured-category-${category.name}`"
class="category-selector"
:model-value="featuredTags.includes(category)"
:description="formatCategory(category.name)"
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
@update:model-value="toggleFeaturedCategory(category)"
>
<div class="category-selector__label">
<div
v-if="category.header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
</template>
<p v-if="project.versions.length === 0" class="known-errors">
Please upload a version first in order to select tags!
</p>
<template v-else>
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div class="label">
<h4>
<span class="label__title">{{ formatCategoryHeader(header) }}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
Select all categories that reflect the themes or function of your
{{ formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'features'">
Select all of the features that your
{{ formatProjectType(project.project_type).toLowerCase() }} makes use of.
</template>
<template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your
{{ formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'performance impact'">
Select the realistic performance impact of your
{{ formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
{{ formatProjectType(project.project_type).toLowerCase() }} is configurable to
different levels of performance impact.
</template>
</span>
</div>
<div class="category-list input-div">
<Checkbox
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:model-value="selectedTags.includes(category)"
:description="formatCategory(category.name)"
class="category-selector"
@update:model-value="toggleCategory(category)"
>
<div class="category-selector__label">
<div
v-if="header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
</template>
<div class="label">
<h4>
<span class="label__title"><StarIcon /> Featured tags</span>
</h4>
<span class="label__description">
You can feature up to 3 of your most relevant tags. Other tags may be promoted to
featured if you do not select all 3.
</span>
</div>
<p v-if="selectedTags.length < 1">
Select at least one category in order to feature a category.
</p>
<div class="category-list input-div">
<Checkbox
v-for="category in selectedTags"
:key="`featured-category-${category.name}`"
class="category-selector"
:model-value="featuredTags.includes(category)"
:description="formatCategory(category.name)"
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
@update:model-value="toggleFeaturedCategory(category)"
>
<div class="category-selector__label">
<div
v-if="category.header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
</template>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { StarIcon, SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import { SaveIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
import {
formatCategory,
formatCategoryHeader,
formatProjectType,
sortedCategories,
type Project,
} from "@modrinth/utils";
import Checkbox from "~/components/ui/Checkbox.vue";
formatCategory,
formatCategoryHeader,
formatProjectType,
type Project,
sortedCategories,
} from '@modrinth/utils'
import { computed, ref } from 'vue'
import Checkbox from '~/components/ui/Checkbox.vue'
interface Category {
name: string;
header: string;
icon?: string;
project_type: string;
name: string
header: string
icon?: string
project_type: string
}
interface Props {
project: Project & {
actualProjectType: string;
};
allMembers?: any[];
currentMember?: any;
patchProject?: (data: any) => void;
project: Project & {
actualProjectType: string
}
allMembers?: any[]
currentMember?: any
patchProject?: (data: any) => void
}
const tags = useTags();
const tags = useTags()
const props = withDefaults(defineProps<Props>(), {
allMembers: () => [],
currentMember: null,
patchProject: () => {
addNotification({
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
},
});
allMembers: () => [],
currentMember: null,
patchProject: () => {
addNotification({
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
},
})
const selectedTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
(props.project.categories.includes(x.name) ||
props.project.additional_categories.includes(x.name)),
),
);
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
(props.project.categories.includes(x.name) ||
props.project.additional_categories.includes(x.name)),
),
)
const featuredTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
props.project.categories.includes(x.name),
),
);
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
props.project.categories.includes(x.name),
),
)
const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {};
sortedCategories(tags.value).forEach((x: Category) => {
if (x.project_type === props.project.actualProjectType) {
const header = x.header;
if (!lists[header]) {
lists[header] = [];
}
lists[header].push(x);
}
});
return lists;
});
const lists: Record<string, Category[]> = {}
sortedCategories(tags.value).forEach((x: Category) => {
if (x.project_type === props.project.actualProjectType) {
const header = x.header
if (!lists[header]) {
lists[header] = []
}
lists[header].push(x)
}
})
return lists
})
const tooManyTagsWarning = computed(() => {
const tagCount = selectedTags.value.length;
if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`;
}
return null;
});
const tagCount = selectedTags.value.length
if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
}
return null
})
const multipleResolutionTagsWarning = computed(() => {
if (props.project.project_type !== "resourcepack") return null;
if (props.project.project_type !== 'resourcepack') return null
const resolutionTags = selectedTags.value.filter((tag) =>
["8x-", "16x", "32x", "48x", "64x", "128x", "256x", "512x+"].includes(tag.name),
);
const resolutionTags = selectedTags.value.filter((tag) =>
['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+'].includes(tag.name),
)
if (resolutionTags.length > 1) {
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags
.map((t) => t.name)
.join(", ")
.replace("8x-", "8x or lower")
.replace(
"512x+",
"512x or higher",
)}). Resource packs should typically only have one resolution tag.`;
}
return null;
});
if (resolutionTags.length > 1) {
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags
.map((t) => t.name)
.join(', ')
.replace('8x-', '8x or lower')
.replace(
'512x+',
'512x or higher',
)}). Resource packs should typically only have one resolution tag.`
}
return null
})
const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === props.project.actualProjectType,
);
const totalSelectedTags = selectedTags.value.length;
const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === props.project.actualProjectType,
)
const totalSelectedTags = selectedTags.value.length
if (
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
) {
return `You've selected all ${categoriesForProjectType.length} available tags. Please select only the tags that truly apply to your project.`;
}
return null;
});
if (
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
) {
return `You've selected all ${categoriesForProjectType.length} available tags. Please select only the tags that truly apply to your project.`
}
return null
})
const patchData = computed(() => {
const data: Record<string, string[]> = {};
const data: Record<string, string[]> = {}
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = featuredTags.value.slice();
if (newFeaturedTags.length < 1 && selectedTags.value.length > newFeaturedTags.length) {
const nonFeaturedCategories = selectedTags.value.filter((x) => !newFeaturedTags.includes(x));
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = featuredTags.value.slice()
if (newFeaturedTags.length < 1 && selectedTags.value.length > newFeaturedTags.length) {
const nonFeaturedCategories = selectedTags.value.filter((x) => !newFeaturedTags.includes(x))
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x));
}
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x))
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = selectedTags.value
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name);
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name)
const additionalCategories = selectedTags.value
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name)
if (
categories.length !== props.project.categories.length ||
categories.some((value) => !props.project.categories.includes(value))
) {
data.categories = categories;
}
if (
categories.length !== props.project.categories.length ||
categories.some((value) => !props.project.categories.includes(value))
) {
data.categories = categories
}
if (
additionalCategories.length !== props.project.additional_categories.length ||
additionalCategories.some((value) => !props.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories;
}
if (
additionalCategories.length !== props.project.additional_categories.length ||
additionalCategories.some((value) => !props.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories
}
return data;
});
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0;
});
return Object.keys(patchData.value).length > 0
})
const toggleCategory = (category: Category) => {
if (selectedTags.value.includes(category)) {
selectedTags.value = selectedTags.value.filter((x) => x !== category);
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
}
} else {
selectedTags.value.push(category);
}
};
if (selectedTags.value.includes(category)) {
selectedTags.value = selectedTags.value.filter((x) => x !== category)
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category)
}
} else {
selectedTags.value.push(category)
}
}
const toggleFeaturedCategory = (category: Category) => {
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
} else {
featuredTags.value.push(category);
}
};
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category)
} else {
featuredTags.value.push(category)
}
}
const saveChanges = () => {
if (hasChanges.value) {
props.patchProject(patchData.value);
}
};
if (hasChanges.value) {
props.patchProject(patchData.value)
}
}
</script>
<style lang="scss" scoped>
.label__title {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
margin-top: var(--spacing-card-bg);
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
margin-top: var(--spacing-card-bg);
svg {
vertical-align: top;
}
svg {
vertical-align: top;
}
}
.button-group {
justify-content: flex-start;
justify-content: flex-start;
}
.category-list {
column-count: 4;
column-gap: var(--spacing-card-lg);
margin-bottom: var(--spacing-card-md);
column-count: 4;
column-gap: var(--spacing-card-lg);
margin-bottom: var(--spacing-card-md);
:deep(.category-selector) {
margin-bottom: 0.5rem;
:deep(.category-selector) {
margin-bottom: 0.5rem;
.category-selector__label {
display: flex;
align-items: center;
.category-selector__label {
display: flex;
align-items: center;
.icon {
height: 1rem;
.icon {
height: 1rem;
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
}
span {
user-select: none;
}
}
@media only screen and (max-width: 1250px) {
column-count: 3;
}
@media only screen and (max-width: 1024px) {
column-count: 4;
}
@media only screen and (max-width: 960px) {
column-count: 3;
}
@media only screen and (max-width: 750px) {
column-count: 2;
}
@media only screen and (max-width: 530px) {
column-count: 1;
}
@media only screen and (max-width: 1250px) {
column-count: 3;
}
@media only screen and (max-width: 1024px) {
column-count: 4;
}
@media only screen and (max-width: 960px) {
column-count: 3;
}
@media only screen and (max-width: 750px) {
column-count: 2;
}
@media only screen and (max-width: 530px) {
column-count: 1;
}
}
</style>

View File

@@ -1,164 +1,164 @@
<template>
<div class="normal-page__content flex flex-col gap-4">
<nuxt-link
:to="versionsListLink"
class="flex w-fit items-center gap-1 text-brand-blue hover:underline"
>
<ChevronLeftIcon />
{{
hasBackLink ? formatMessage(messages.backToVersions) : formatMessage(messages.allVersions)
}}
</nuxt-link>
<div class="flex gap-3">
<VersionChannelIndicator :channel="version.version_type" large />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<span class="text-sm font-semibold text-secondary"> {{ version.name }} </span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button><DownloadIcon /> Download</button>
</ButtonStyled>
<ButtonStyled>
<button><ShareIcon /> Share</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</div>
<div>
<h2 class="text-lg font-extrabold text-contrast">Files</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(file, index) in version.files"
:key="index"
class="flex gap-2 rounded-2xl bg-bg-raised p-4"
>
<div
:class="`flex h-9 w-9 items-center justify-center rounded-full ${file.primary ? 'bg-brand-highlight text-brand' : 'bg-button-bg text-secondary'}`"
>
<FileIcon />
</div>
<div class="flex flex-grow flex-col">
<span class="font-extrabold text-contrast">{{
file.primary ? "Primary file" : "Supplementary resource"
}}</span>
<span class="text-sm font-semibold text-secondary"
>{{ file.filename }} {{ formatBytes(file.size) }}</span
>
</div>
<div>
<ButtonStyled circular type="transparent">
<button>
<DownloadIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
<h2 class="text-lg font-extrabold text-contrast">Dependencies</h2>
<h2 class="text-lg font-extrabold text-contrast">Changes</h2>
<div class="rounded-2xl bg-bg-raised px-6 py-4">
<div
class="markdown-body"
v-html="renderHighlightedString(version.changelog ?? 'No changelog provided')"
/>
</div>
</div>
</div>
<div class="normal-page__sidebar">
<div class="padding-lg h-[250px] rounded-2xl bg-bg-raised"></div>
</div>
<div class="normal-page__content flex flex-col gap-4">
<nuxt-link
:to="versionsListLink"
class="flex w-fit items-center gap-1 text-brand-blue hover:underline"
>
<ChevronLeftIcon />
{{
hasBackLink ? formatMessage(messages.backToVersions) : formatMessage(messages.allVersions)
}}
</nuxt-link>
<div class="flex gap-3">
<VersionChannelIndicator :channel="version.version_type" large />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<span class="text-sm font-semibold text-secondary"> {{ version.name }} </span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button><DownloadIcon /> Download</button>
</ButtonStyled>
<ButtonStyled>
<button><ShareIcon /> Share</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</div>
<div>
<h2 class="text-lg font-extrabold text-contrast">Files</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(file, index) in version.files"
:key="index"
class="flex gap-2 rounded-2xl bg-bg-raised p-4"
>
<div
:class="`flex h-9 w-9 items-center justify-center rounded-full ${file.primary ? 'bg-brand-highlight text-brand' : 'bg-button-bg text-secondary'}`"
>
<FileIcon />
</div>
<div class="flex flex-grow flex-col">
<span class="font-extrabold text-contrast">{{
file.primary ? 'Primary file' : 'Supplementary resource'
}}</span>
<span class="text-sm font-semibold text-secondary"
>{{ file.filename }} {{ formatBytes(file.size) }}</span
>
</div>
<div>
<ButtonStyled circular type="transparent">
<button>
<DownloadIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
<h2 class="text-lg font-extrabold text-contrast">Dependencies</h2>
<h2 class="text-lg font-extrabold text-contrast">Changes</h2>
<div class="rounded-2xl bg-bg-raised px-6 py-4">
<div
class="markdown-body"
v-html="renderHighlightedString(version.changelog ?? 'No changelog provided')"
/>
</div>
</div>
</div>
<div class="normal-page__sidebar">
<div class="padding-lg h-[250px] rounded-2xl bg-bg-raised"></div>
</div>
</template>
<script setup lang="ts">
import {
ChevronLeftIcon,
DownloadIcon,
FileIcon,
MoreVerticalIcon,
ShareIcon,
} from "@modrinth/assets";
import { ButtonStyled, VersionChannelIndicator } from "@modrinth/ui";
import { formatBytes, renderHighlightedString } from "@modrinth/utils";
ChevronLeftIcon,
DownloadIcon,
FileIcon,
MoreVerticalIcon,
ShareIcon,
} from '@modrinth/assets'
import { ButtonStyled, VersionChannelIndicator } from '@modrinth/ui'
import { formatBytes, renderHighlightedString } from '@modrinth/utils'
const router = useRouter();
const router = useRouter()
const props = defineProps<{
project: Project;
versions: Version[];
featuredVersions: Version[];
members: User[];
currentMember: User;
dependencies: Dependency[];
resetProject: (opts?: { dedupe?: "cancel" | "defer" }) => Promise<void>;
}>();
project: Project
versions: Version[]
featuredVersions: Version[]
members: User[]
currentMember: User
dependencies: Dependency[]
resetProject: (opts?: { dedupe?: 'cancel' | 'defer' }) => Promise<void>
}>()
const version = computed(() => {
let version: Version | undefined;
let version: Version | undefined
if (route.params.version === "latest") {
let versionList = props.versions;
if (route.query.loader) {
versionList = versionList.filter((x) => x.loaders.includes(route.query.loader));
}
if (route.query.version) {
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
}
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
} else {
version = props.versions.find(
(x) => x.id === route.params.version || x.displayUrlEnding === route.params.version,
);
}
if (route.params.version === 'latest') {
let versionList = props.versions
if (route.query.loader) {
versionList = versionList.filter((x) => x.loaders.includes(route.query.loader))
}
if (route.query.version) {
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version))
}
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b))
} else {
version = props.versions.find(
(x) => x.id === route.params.version || x.displayUrlEnding === route.params.version,
)
}
if (!version) {
throw createError({
fatal: true,
statusCode: 404,
message: "Version not found",
});
}
if (!version) {
throw createError({
fatal: true,
statusCode: 404,
message: 'Version not found',
})
}
return version;
});
return version
})
// const data = useNuxtApp();
const route = useNativeRoute();
const route = useNativeRoute()
// const auth = await useAuth();
// const tags = useTags();
const versionsListLink = computed(() => {
if (router.options.history.state.back) {
if (router.options.history.state.back.includes("/versions")) {
return router.options.history.state.back;
}
}
return `/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/versions`;
});
if (router.options.history.state.back) {
if (router.options.history.state.back.includes('/versions')) {
return router.options.history.state.back
}
}
return `/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/versions`
})
const hasBackLink = computed(
() =>
router.options.history.state.back && router.options.history.state.back.endsWith("/versions"),
);
() =>
router.options.history.state.back && router.options.history.state.back.endsWith('/versions'),
)
const { formatMessage } = useVIntl();
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToVersions: {
id: "project.version.back-to-versions",
defaultMessage: "Back to versions",
},
allVersions: {
id: "project.version.all-versions",
defaultMessage: "All versions",
},
});
backToVersions: {
id: 'project.version.back-to-versions',
defaultMessage: 'Back to versions',
},
allVersions: {
id: 'project.version.all-versions',
defaultMessage: 'All versions',
},
})
</script>
<style lang="scss"></style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
<template>
<div />
<div />
</template>
<script setup>
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
</script>

View File

@@ -1,265 +1,266 @@
<template>
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<section class="experimental-styles-within overflow-visible">
<div
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
class="card flex items-center gap-4"
>
<FileInput
:max-size="524288000"
:accept="acceptFileFromProjectType(project.project_type)"
prompt="Upload a version"
class="btn btn-primary"
aria-label="Upload a version"
@change="handleFiles"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<span class="flex items-center gap-2">
<InfoIcon aria-hidden="true" /> Click to choose a file or drag one onto this page
</span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<ProjectPageVersions
:project="project"
:versions="versions"
:show-files="flags.showVersionFilesInTable"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
:base-id="baseDropdownId"
:version-link="
(version) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
aria-label="Download"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
class="group-hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emit('onDownload');
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
shown: !currentMember,
},
{ divider: true, shown: currentMember || flags.developerMode },
{
id: 'copy-id',
action: () => {
copyToClipboard(version.id);
},
shown: currentMember || flags.developerMode,
},
{
id: 'copy-maven',
action: () => {
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`);
},
shown: flags.developerMode,
},
{ divider: true, shown: currentMember },
{
id: 'edit',
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
shown: currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {
selectedVersion = version.id;
deleteVersionModal.show();
},
shown: currentMember,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #download>
<DownloadIcon aria-hidden="true" />
Download
</template>
<template #new-tab>
<ExternalIcon aria-hidden="true" />
Open in new tab
</template>
<template #copy-link>
<LinkIcon aria-hidden="true" />
Copy link
</template>
<template #share>
<ShareIcon aria-hidden="true" />
Share
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit>
<EditIcon aria-hidden="true" />
Edit
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectPageVersions>
</section>
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<section class="experimental-styles-within overflow-visible">
<div
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
class="card flex items-center gap-4"
>
<FileInput
:max-size="524288000"
:accept="acceptFileFromProjectType(project.project_type)"
prompt="Upload a version"
class="btn btn-primary"
aria-label="Upload a version"
@change="handleFiles"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<span class="flex items-center gap-2">
<InfoIcon aria-hidden="true" /> Click to choose a file or drag one onto this page
</span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<ProjectPageVersions
:project="project"
:versions="versions"
:show-files="flags.showVersionFilesInTable"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
:base-id="baseDropdownId"
:version-link="
(version) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
aria-label="Download"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
class="group-hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emit('onDownload')
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
shown: !currentMember,
},
{ divider: true, shown: currentMember || flags.developerMode },
{
id: 'copy-id',
action: () => {
copyToClipboard(version.id)
},
shown: currentMember || flags.developerMode,
},
{
id: 'copy-maven',
action: () => {
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`)
},
shown: flags.developerMode,
},
{ divider: true, shown: currentMember },
{
id: 'edit',
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
shown: currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {
selectedVersion = version.id
deleteVersionModal.show()
},
shown: currentMember,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #download>
<DownloadIcon aria-hidden="true" />
Download
</template>
<template #new-tab>
<ExternalIcon aria-hidden="true" />
Open in new tab
</template>
<template #copy-link>
<LinkIcon aria-hidden="true" />
Copy link
</template>
<template #share>
<ShareIcon aria-hidden="true" />
Share
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit>
<EditIcon aria-hidden="true" />
Edit
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectPageVersions>
</section>
</template>
<script setup>
import {
ButtonStyled,
OverflowMenu,
FileInput,
ProjectPageVersions,
ConfirmModal,
} from "@modrinth/ui";
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
InfoIcon,
LinkIcon,
MoreVerticalIcon,
ReportIcon,
ShareIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import {
DownloadIcon,
MoreVerticalIcon,
TrashIcon,
ExternalIcon,
LinkIcon,
ShareIcon,
EditIcon,
ReportIcon,
UploadIcon,
InfoIcon,
ClipboardCopyIcon,
} from "@modrinth/assets";
import DropArea from "~/components/ui/DropArea.vue";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
ButtonStyled,
ConfirmModal,
FileInput,
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import DropArea from '~/components/ui/DropArea.vue'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
const props = defineProps({
project: {
type: Object,
default() {
return {};
},
},
versions: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
});
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
})
const tags = useTags();
const flags = useFeatureFlags();
const auth = await useAuth();
const tags = useTags()
const flags = useFeatureFlags()
const auth = await useAuth()
const deleteVersionModal = ref();
const selectedVersion = ref(null);
const deleteVersionModal = ref()
const selectedVersion = ref(null)
const emit = defineEmits(["onDownload", "deleteVersion"]);
const emit = defineEmits(['onDownload', 'deleteVersion'])
const router = useNativeRouter();
const router = useNativeRouter()
const baseDropdownId = useId();
const baseDropdownId = useId()
function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0];
return version.files.find((x) => x.primary) || version.files[0]
}
async function handleFiles(files) {
await router.push({
name: "type-id-version-version",
params: {
type: props.project.project_type,
id: props.project.slug ? props.project.slug : props.project.id,
version: "create",
},
state: {
newPrimaryFile: files[0],
},
});
await router.push({
name: 'type-id-version-version',
params: {
type: props.project.project_type,
id: props.project.slug ? props.project.slug : props.project.id,
version: 'create',
},
state: {
newPrimaryFile: files[0],
},
})
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text);
await navigator.clipboard.writeText(text)
}
function deleteVersion() {
emit("deleteVersion", selectedVersion.value);
selectedVersion.value = null;
emit('deleteVersion', selectedVersion.value)
selectedVersion.value = null
}
</script>

View File

@@ -1,443 +1,444 @@
<template>
<NewModal ref="refundModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Refund type
<span class="text-brand-red">*</span>
</span>
<span> The type of refund to issue. </span>
</label>
<DropdownSelect
id="refund-type"
v-model="refundType"
:options="refundTypes"
name="Refund type"
/>
</div>
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
<label for="amount" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Amount
<span class="text-brand-red">*</span>
</span>
<span>
Enter the amount in cents of USD. For example for $2, enter 200. (net
{{ selectedCharge.net }})
</span>
</label>
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label for="unprovision" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Unprovision
<span class="text-brand-red">*</span>
</span>
<span> Whether or not the subscription should be unprovisioned on refund. </span>
</label>
<Toggle id="unprovision" v-model="unprovision" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="refunding" @click="refundCharge">
<CheckIcon aria-hidden="true" />
Refund charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="refundModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="modifyModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Modify charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="cancel" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Cancel server
<span class="text-brand-red">*</span>
</span>
<span>
Whether or not the subscription should be cancelled. Submitting this as "true" will
cancel the subscription, while submitting it as "false" will force another charge
attempt to be made.
</span>
</label>
<Toggle id="cancel" v-model="cancel" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="modifying" @click="modifyCharge">
<CheckIcon aria-hidden="true" />
Modify charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modifyModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="page experimental-styles-within">
<div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
>
<div class="flex items-center gap-2">
<Avatar :src="user.avatar_url" :alt="user.username" size="32px" circle />
<h1 class="m-0 text-2xl font-extrabold">{{ user.username }}'s subscriptions</h1>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<nuxt-link :to="`/user/${user.id}`">
<UserIcon aria-hidden="true" />
User profile
<ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div>
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
<div class="mb-4 grid grid-cols-[1fr_auto]">
<div>
<span class="flex items-center gap-2 font-semibold text-contrast">
<template v-if="subscription.product.metadata.type === 'midas'">
<ModrinthPlusIcon class="h-7 w-min" />
</template>
<template v-else-if="subscription.product.metadata.type === 'pyro'">
<ModrinthServersIcon class="h-7 w-min" />
</template>
<template v-else> Unknown product </template>
</span>
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
{{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
formatRelativeTime(subscription.created)
}})
</div>
</div>
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
<nuxt-link
:to="`/servers/manage/${subscription.metadata.id}`"
target="_blank"
class="w-fit"
>
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
<CopyCode :text="subscription.metadata.id" />
</div>
</div>
<div class="flex flex-col gap-2">
<div
v-for="(charge, index) in subscription.charges"
:key="charge.id"
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
>
<div
class="absolute bottom-0 left-0 top-0 w-1"
:class="charge.type === 'refund' ? 'bg-purple' : chargeStatuses[charge.status].color"
/>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
<div class="flex flex-col gap-2">
<span>
<span class="font-bold text-contrast">
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
<template v-else-if="charge.status === 'failed'"> Failed </template>
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
<template v-else-if="charge.status === 'processing'"> Processing </template>
<template v-else-if="charge.status === 'open'"> Upcoming </template>
<template v-else> {{ charge.status }} </template>
</span>
<span>
<template v-if="charge.type === 'refund'"> Refund </template>
<template v-else-if="charge.type === 'subscription'">
<template v-if="charge.status === 'cancelled'"> Subscription </template>
<template v-else-if="index === subscription.charges.length - 1">
Started subscription
</template>
<template v-else> Subscription renewal </template>
</template>
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
<template v-else> {{ charge.status }} </template>
</span>
<template v-if="charge.status !== 'cancelled'">
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}
{{ charge.type }}
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
{{ dayjs(charge.due).format("YYYY-MM-DD h:mma") }}
<template v-if="charge.subscription_interval">
⋅ {{ charge.subscription_interval }}
</template>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled
v-if="
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
"
>
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
color="red"
color-fill="text"
>
<button @click="showRefundModal(charge)">
<CurrencyIcon />
Refund options
</button>
</ButtonStyled>
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
<button @click="showModifyModal(subscription)">
<CurrencyIcon />
Modify charge
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<NewModal ref="refundModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Refund type
<span class="text-brand-red">*</span>
</span>
<span> The type of refund to issue. </span>
</label>
<DropdownSelect
id="refund-type"
v-model="refundType"
:options="refundTypes"
name="Refund type"
/>
</div>
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
<label for="amount" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Amount
<span class="text-brand-red">*</span>
</span>
<span>
Enter the amount in cents of USD. For example for $2, enter 200. (net
{{ selectedCharge.net }})
</span>
</label>
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label for="unprovision" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Unprovision
<span class="text-brand-red">*</span>
</span>
<span> Whether or not the subscription should be unprovisioned on refund. </span>
</label>
<Toggle id="unprovision" v-model="unprovision" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="refunding" @click="refundCharge">
<CheckIcon aria-hidden="true" />
Refund charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="refundModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="modifyModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Modify charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="cancel" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Cancel server
<span class="text-brand-red">*</span>
</span>
<span>
Whether or not the subscription should be cancelled. Submitting this as "true" will
cancel the subscription, while submitting it as "false" will force another charge
attempt to be made.
</span>
</label>
<Toggle id="cancel" v-model="cancel" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="modifying" @click="modifyCharge">
<CheckIcon aria-hidden="true" />
Modify charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modifyModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="page experimental-styles-within">
<div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
>
<div class="flex items-center gap-2">
<Avatar :src="user.avatar_url" :alt="user.username" size="32px" circle />
<h1 class="m-0 text-2xl font-extrabold">{{ user.username }}'s subscriptions</h1>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<nuxt-link :to="`/user/${user.id}`">
<UserIcon aria-hidden="true" />
User profile
<ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div>
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
<div class="mb-4 grid grid-cols-[1fr_auto]">
<div>
<span class="flex items-center gap-2 font-semibold text-contrast">
<template v-if="subscription.product.metadata.type === 'midas'">
<ModrinthPlusIcon class="h-7 w-min" />
</template>
<template v-else-if="subscription.product.metadata.type === 'pyro'">
<ModrinthServersIcon class="h-7 w-min" />
</template>
<template v-else> Unknown product </template>
</span>
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
{{ dayjs(subscription.created).format('MMMM D, YYYY [at] h:mma') }} ({{
formatRelativeTime(subscription.created)
}})
</div>
</div>
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
<nuxt-link
:to="`/servers/manage/${subscription.metadata.id}`"
target="_blank"
class="w-fit"
>
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
<CopyCode :text="subscription.metadata.id" />
</div>
</div>
<div class="flex flex-col gap-2">
<div
v-for="(charge, index) in subscription.charges"
:key="charge.id"
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
>
<div
class="absolute bottom-0 left-0 top-0 w-1"
:class="charge.type === 'refund' ? 'bg-purple' : chargeStatuses[charge.status].color"
/>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
<div class="flex flex-col gap-2">
<span>
<span class="font-bold text-contrast">
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
<template v-else-if="charge.status === 'failed'"> Failed </template>
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
<template v-else-if="charge.status === 'processing'"> Processing </template>
<template v-else-if="charge.status === 'open'"> Upcoming </template>
<template v-else> {{ charge.status }} </template>
</span>
<span>
<template v-if="charge.type === 'refund'"> Refund </template>
<template v-else-if="charge.type === 'subscription'">
<template v-if="charge.status === 'cancelled'"> Subscription </template>
<template v-else-if="index === subscription.charges.length - 1">
Started subscription
</template>
<template v-else> Subscription renewal </template>
</template>
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
<template v-else> {{ charge.status }} </template>
</span>
<template v-if="charge.status !== 'cancelled'">
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format('MMMM D, YYYY [at] h:mma') }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format('MMMM D, YYYY [at] h:mma') }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}
{{ charge.type }}
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
{{ dayjs(charge.due).format('YYYY-MM-DD h:mma') }}
<template v-if="charge.subscription_interval">
⋅ {{ charge.subscription_interval }}
</template>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled
v-if="
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
"
>
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
color="red"
color-fill="text"
>
<button @click="showRefundModal(charge)">
<CurrencyIcon />
Refund options
</button>
</ButtonStyled>
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
<button @click="showModifyModal(subscription)">
<CurrencyIcon />
Modify charge
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
CheckIcon,
CurrencyIcon,
ExternalIcon,
ModrinthPlusIcon,
ServerIcon,
UserIcon,
XIcon,
} from "@modrinth/assets";
CheckIcon,
CurrencyIcon,
ExternalIcon,
ModrinthPlusIcon,
ServerIcon,
UserIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
CopyCode,
DropdownSelect,
injectNotificationManager,
NewModal,
Toggle,
useRelativeTime,
} from "@modrinth/ui";
import { formatCategory, formatPrice } from "@modrinth/utils";
import dayjs from "dayjs";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
import { products } from "~/generated/state.json";
Avatar,
ButtonStyled,
CopyCode,
DropdownSelect,
injectNotificationManager,
NewModal,
Toggle,
useRelativeTime,
} from '@modrinth/ui'
import { formatCategory, formatPrice } from '@modrinth/utils'
import dayjs from 'dayjs'
const { addNotification } = injectNotificationManager();
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
import { products } from '~/generated/state.json'
const route = useRoute();
const vintl = useVIntl();
const { addNotification } = injectNotificationManager()
const { formatMessage } = vintl;
const formatRelativeTime = useRelativeTime();
const route = useRoute()
const vintl = useVIntl()
const { formatMessage } = vintl
const formatRelativeTime = useRelativeTime()
const messages = defineMessages({
userNotFoundError: {
id: "admin.billing.error.not-found",
defaultMessage: "User not found",
},
});
userNotFoundError: {
id: 'admin.billing.error.not-found',
defaultMessage: 'User not found',
},
})
const { data: user } = await useAsyncData(`user/${route.params.id}`, () =>
useBaseFetch(`user/${route.params.id}`),
);
useBaseFetch(`user/${route.params.id}`),
)
if (!user.value) {
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.userNotFoundError),
});
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.userNotFoundError),
})
}
let subscriptions, charges, refreshCharges;
let subscriptions, charges, refreshCharges
try {
[{ data: subscriptions }, { data: charges, refresh: refreshCharges }] = await Promise.all([
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
internal: true,
}),
),
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
internal: true,
}),
),
]);
;[{ data: subscriptions }, { data: charges, refresh: refreshCharges }] = await Promise.all([
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
internal: true,
}),
),
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
internal: true,
}),
),
])
} catch {
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.userNotFoundError),
});
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.userNotFoundError),
})
}
const subscriptionCharges = computed(() => {
return subscriptions.value.map((subscription) => {
return {
...subscription,
charges: charges.value
.filter((charge) => charge.subscription_id === subscription.id)
.slice()
.sort((a, b) => dayjs(b.due).diff(dayjs(a.due))),
product: products.find((product) =>
product.prices.some((price) => price.id === subscription.price_id),
),
};
});
});
return subscriptions.value.map((subscription) => {
return {
...subscription,
charges: charges.value
.filter((charge) => charge.subscription_id === subscription.id)
.slice()
.sort((a, b) => dayjs(b.due).diff(dayjs(a.due))),
product: products.find((product) =>
product.prices.some((price) => price.id === subscription.price_id),
),
}
})
})
const refunding = ref(false);
const refundModal = ref();
const selectedCharge = ref(null);
const refundType = ref("full");
const refundTypes = ref(["full", "partial", "none"]);
const refundAmount = ref(0);
const unprovision = ref(true);
const refunding = ref(false)
const refundModal = ref()
const selectedCharge = ref(null)
const refundType = ref('full')
const refundTypes = ref(['full', 'partial', 'none'])
const refundAmount = ref(0)
const unprovision = ref(true)
const modifying = ref(false);
const modifyModal = ref();
const cancel = ref(false);
const modifying = ref(false)
const modifyModal = ref()
const cancel = ref(false)
function showRefundModal(charge) {
selectedCharge.value = charge;
refundType.value = "full";
refundAmount.value = 0;
unprovision.value = true;
refundModal.value.show();
selectedCharge.value = charge
refundType.value = 'full'
refundAmount.value = 0
unprovision.value = true
refundModal.value.show()
}
function showModifyModal(charge) {
selectedCharge.value = charge;
cancel.value = false;
modifyModal.value.show();
selectedCharge.value = charge
cancel.value = false
modifyModal.value.show()
}
async function refundCharge() {
refunding.value = true;
try {
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
method: "POST",
body: JSON.stringify({
type: refundType.value,
amount: refundAmount.value,
unprovision: unprovision.value,
}),
internal: true,
});
await refreshCharges();
refundModal.value.hide();
} catch (err) {
addNotification({
title: "Error refunding",
text: err.data?.description ?? err,
type: "error",
});
}
refunding.value = false;
refunding.value = true
try {
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
method: 'POST',
body: JSON.stringify({
type: refundType.value,
amount: refundAmount.value,
unprovision: unprovision.value,
}),
internal: true,
})
await refreshCharges()
refundModal.value.hide()
} catch (err) {
addNotification({
title: 'Error refunding',
text: err.data?.description ?? err,
type: 'error',
})
}
refunding.value = false
}
async function modifyCharge() {
modifying.value = true;
try {
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
method: "PATCH",
body: JSON.stringify({
cancelled: cancel.value,
}),
internal: true,
});
addNotification({
title: "Resubscription request submitted",
text: "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.",
type: "success",
});
await refreshCharges();
} catch (err) {
addNotification({
title: "Error reattempting charge",
text: err.data?.description ?? err,
type: "error",
});
}
modifying.value = false;
modifying.value = true
try {
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
method: 'PATCH',
body: JSON.stringify({
cancelled: cancel.value,
}),
internal: true,
})
addNotification({
title: 'Resubscription request submitted',
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
type: 'success',
})
await refreshCharges()
} catch (err) {
addNotification({
title: 'Error reattempting charge',
text: err.data?.description ?? err,
type: 'error',
})
}
modifying.value = false
}
const chargeStatuses = {
open: {
color: "bg-blue",
},
processing: {
color: "bg-orange",
},
succeeded: {
color: "bg-green",
},
failed: {
color: "bg-red",
},
cancelled: {
color: "bg-red",
},
};
open: {
color: 'bg-blue',
},
processing: {
color: 'bg-orange',
},
succeeded: {
color: 'bg-green',
},
failed: {
color: 'bg-red',
},
cancelled: {
color: 'bg-red',
},
}
</script>
<style scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
</style>

View File

@@ -1,502 +1,506 @@
<template>
<NewModal ref="createNoticeModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">{{
editingNotice ? `Editing notice #${editingNotice?.id}` : "Creating a notice"
}}</span>
</template>
<div class="flex w-[700px] flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<label for="level-selector" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Level </span>
<span>Determines how the notice should be styled.</span>
</label>
<TeleportDropdownMenu
id="level-selector"
v-model="newNoticeLevel"
class="max-w-[10rem]"
:options="levelOptions"
:display-name="(x) => formatMessage(x.name)"
name="Level"
/>
</div>
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
<label for="notice-title" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Title </span>
</label>
<input
id="notice-title"
v-model="newNoticeTitle"
placeholder="E.g. Maintenance"
type="text"
autocomplete="off"
/>
</div>
<div class="flex flex-col gap-2">
<label for="notice-message" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
{{ newNoticeSurvey ? "Survey ID" : "Message" }}
<span class="text-brand-red">*</span>
</span>
</label>
<input
v-if="newNoticeSurvey"
id="notice-message"
v-model="newNoticeMessage"
placeholder="E.g. rXGtq2"
type="text"
autocomplete="off"
/>
<div v-else class="textarea-wrapper h-32">
<textarea id="notice-message" v-model="newNoticeMessage" />
</div>
</div>
<div v-if="!newNoticeSurvey" class="flex items-center justify-between gap-2">
<label for="dismissable-toggle" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Dismissable </span>
<span>Allow users to dismiss the notice from their panel.</span>
</label>
<Toggle id="dismissable-toggle" v-model="newNoticeDismissable" />
</div>
<div class="flex items-center justify-between gap-2">
<label for="scheduled-date" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Announcement date </span>
<span>Leave blank for notice to be available immediately.</span>
</label>
<input
id="scheduled-date"
v-model="newNoticeScheduledDate"
type="datetime-local"
autocomplete="off"
/>
</div>
<div class="flex items-center justify-between gap-2">
<label for="expiration-date" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Expiration date </span>
<span>The notice will automatically be deleted after this date.</span>
</label>
<input
id="expiration-date"
v-model="newNoticeExpiresDate"
type="datetime-local"
autocomplete="off"
/>
</div>
<NewModal ref="createNoticeModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">{{
editingNotice ? `Editing notice #${editingNotice?.id}` : 'Creating a notice'
}}</span>
</template>
<div class="flex w-[700px] flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<label for="level-selector" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Level </span>
<span>Determines how the notice should be styled.</span>
</label>
<TeleportDropdownMenu
id="level-selector"
v-model="newNoticeLevel"
class="max-w-[10rem]"
:options="levelOptions"
:display-name="(x) => formatMessage(x.name)"
name="Level"
/>
</div>
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
<label for="notice-title" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Title </span>
</label>
<input
id="notice-title"
v-model="newNoticeTitle"
placeholder="E.g. Maintenance"
type="text"
autocomplete="off"
/>
</div>
<div class="flex flex-col gap-2">
<label for="notice-message" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
{{ newNoticeSurvey ? 'Survey ID' : 'Message' }}
<span class="text-brand-red">*</span>
</span>
</label>
<input
v-if="newNoticeSurvey"
id="notice-message"
v-model="newNoticeMessage"
placeholder="E.g. rXGtq2"
type="text"
autocomplete="off"
/>
<div v-else class="textarea-wrapper h-32">
<textarea id="notice-message" v-model="newNoticeMessage" />
</div>
</div>
<div v-if="!newNoticeSurvey" class="flex items-center justify-between gap-2">
<label for="dismissable-toggle" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Dismissable </span>
<span>Allow users to dismiss the notice from their panel.</span>
</label>
<Toggle id="dismissable-toggle" v-model="newNoticeDismissable" />
</div>
<div class="flex items-center justify-between gap-2">
<label for="scheduled-date" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Announcement date </span>
<span>Leave blank for notice to be available immediately.</span>
</label>
<input
id="scheduled-date"
v-model="newNoticeScheduledDate"
type="datetime-local"
autocomplete="off"
/>
</div>
<div class="flex items-center justify-between gap-2">
<label for="expiration-date" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Expiration date </span>
<span>The notice will automatically be deleted after this date.</span>
</label>
<input
id="expiration-date"
v-model="newNoticeExpiresDate"
type="datetime-local"
autocomplete="off"
/>
</div>
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast"> Preview </span>
<ServerNotice
:level="newNoticeLevel.id"
:message="
!trimmedMessage || trimmedMessage.length < 1
? 'Type a message to begin previewing it.'
: trimmedMessage
"
:dismissable="newNoticeDismissable"
:title="trimmedTitle"
preview
/>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button v-if="editingNotice" :disabled="!!noticeSubmitError" @click="() => saveChanges()">
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
<button v-else :disabled="!!noticeSubmitError" @click="() => createNotice()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNotice) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="createNoticeModal?.hide">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<AssignNoticeModal ref="assignNoticeModal" @close="refreshNotices" />
<div class="page experimental-styles-within">
<div
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
>
<h1 class="m-0 text-2xl">Servers notices</h1>
<ButtonStyled color="brand">
<button @click="openNewNoticeModal">
<PlusIcon />
{{ formatMessage(messages.createNotice) }}
</button>
</ButtonStyled>
</div>
<div>
<div v-if="!notices || notices.length === 0">{{ formatMessage(messages.noNotices) }}</div>
<div
v-else
class="grid grid-cols-[auto_auto_auto] gap-4 md:grid-cols-[min-content_auto_auto_auto_auto_min-content]"
>
<div class="col-span-full grid grid-cols-subgrid gap-4 px-4 font-bold text-contrast">
<div>{{ formatMessage(messages.id) }}</div>
<div>{{ formatMessage(messages.begins) }}</div>
<div>{{ formatMessage(messages.expires) }}</div>
<div class="hidden md:block">{{ formatMessage(messages.level) }}</div>
<div class="hidden md:block">{{ formatMessage(messages.dismissable) }}</div>
<div class="hidden md:block">{{ formatMessage(messages.actions) }}</div>
</div>
<div
v-for="notice in notices"
:key="`notice-${notice.id}`"
class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4"
>
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
<div>
<CopyCode :text="`${notice.id}`" />
</div>
<div class="text-sm">
<span v-if="notice.announce_at">
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
formatRelativeTime(notice.announce_at)
}})
</span>
<template v-else> Never begins </template>
</div>
<div class="text-sm">
<span
v-if="notice.expires"
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
>
{{ formatRelativeTime(notice.expires) }}
</span>
<template v-else> Never expires </template>
</div>
<div
:style="
NOTICE_LEVELS[notice.level]
? {
'--_color': NOTICE_LEVELS[notice.level].colors.text,
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
}
: undefined
"
>
<TagItem>
{{
NOTICE_LEVELS[notice.level]
? formatMessage(NOTICE_LEVELS[notice.level].name)
: notice.level
}}
</TagItem>
</div>
<div
:style="{
'--_color': notice.dismissable ? 'var(--color-green)' : 'var(--color-red)',
'--_bg-color': notice.dismissable ? 'var(--color-green-bg)' : 'var(--color-red-bg)',
}"
>
<TagItem>
{{
formatMessage(notice.dismissable ? messages.dismissable : messages.undismissable)
}}
</TagItem>
</div>
<div class="col-span-2 flex gap-2 md:col-span-1">
<ButtonStyled>
<button @click="() => startEditing(notice)">
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="() => deleteNotice(notice)">
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="col-span-full grid">
<ServerNotice
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
preview
/>
<div class="mt-4 flex items-center gap-2">
<span v-if="!notice.assigned || notice.assigned.length === 0"
>Not assigned to any servers</span
>
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
Assigned to
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
</span>
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
Assigned to
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers
</span>
<span v-else>
Assigned to
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers and
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
</span>
<button
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
@click="() => startEditing(notice, true)"
>
<SettingsIcon />
Edit assignments
</button>
<template v-if="notice.dismissed_by.length > 0">
<span> Dismissed by {{ notice.dismissed_by.length }} servers </span>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast"> Preview </span>
<ServerNotice
:level="newNoticeLevel.id"
:message="
!trimmedMessage || trimmedMessage.length < 1
? 'Type a message to begin previewing it.'
: trimmedMessage
"
:dismissable="newNoticeDismissable"
:title="trimmedTitle"
preview
/>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button v-if="editingNotice" :disabled="!!noticeSubmitError" @click="() => saveChanges()">
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
<button v-else :disabled="!!noticeSubmitError" @click="() => createNotice()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNotice) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="createNoticeModal?.hide">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<AssignNoticeModal ref="assignNoticeModal" @close="refreshNotices" />
<div class="page experimental-styles-within">
<div
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
>
<h1 class="m-0 text-2xl">Servers notices</h1>
<ButtonStyled color="brand">
<button @click="openNewNoticeModal">
<PlusIcon />
{{ formatMessage(messages.createNotice) }}
</button>
</ButtonStyled>
</div>
<div>
<div v-if="!notices || notices.length === 0">
{{ formatMessage(messages.noNotices) }}
</div>
<div
v-else
class="grid grid-cols-[auto_auto_auto] gap-4 md:grid-cols-[min-content_auto_auto_auto_auto_min-content]"
>
<div class="col-span-full grid grid-cols-subgrid gap-4 px-4 font-bold text-contrast">
<div>{{ formatMessage(messages.id) }}</div>
<div>{{ formatMessage(messages.begins) }}</div>
<div>{{ formatMessage(messages.expires) }}</div>
<div class="hidden md:block">{{ formatMessage(messages.level) }}</div>
<div class="hidden md:block">{{ formatMessage(messages.dismissable) }}</div>
<div class="hidden md:block">{{ formatMessage(messages.actions) }}</div>
</div>
<div
v-for="notice in notices"
:key="`notice-${notice.id}`"
class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4"
>
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
<div>
<CopyCode :text="`${notice.id}`" />
</div>
<div class="text-sm">
<span v-if="notice.announce_at">
{{ dayjs(notice.announce_at).format('MMM D, YYYY [at] h:mm A') }}
({{ formatRelativeTime(notice.announce_at) }})
</span>
<template v-else> Never begins </template>
</div>
<div class="text-sm">
<span
v-if="notice.expires"
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
>
{{ formatRelativeTime(notice.expires) }}
</span>
<template v-else> Never expires </template>
</div>
<div
:style="
NOTICE_LEVELS[notice.level]
? {
'--_color': NOTICE_LEVELS[notice.level].colors.text,
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
}
: undefined
"
>
<TagItem>
{{
NOTICE_LEVELS[notice.level]
? formatMessage(NOTICE_LEVELS[notice.level].name)
: notice.level
}}
</TagItem>
</div>
<div
:style="{
'--_color': notice.dismissable ? 'var(--color-green)' : 'var(--color-red)',
'--_bg-color': notice.dismissable ? 'var(--color-green-bg)' : 'var(--color-red-bg)',
}"
>
<TagItem>
{{
formatMessage(notice.dismissable ? messages.dismissable : messages.undismissable)
}}
</TagItem>
</div>
<div class="col-span-2 flex gap-2 md:col-span-1">
<ButtonStyled>
<button @click="() => startEditing(notice)">
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="() => deleteNotice(notice)">
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="col-span-full grid">
<ServerNotice
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
preview
/>
<div class="mt-4 flex items-center gap-2">
<span v-if="!notice.assigned || notice.assigned.length === 0"
>Not assigned to any servers</span
>
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
Assigned to
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
</span>
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
Assigned to
{{ notice.assigned.filter((n) => n.kind === 'server').length }}
servers
</span>
<span v-else>
Assigned to
{{ notice.assigned.filter((n) => n.kind === 'server').length }}
servers and
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
</span>
<button
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
@click="() => startEditing(notice, true)"
>
<SettingsIcon />
Edit assignments
</button>
<template v-if="notice.dismissed_by.length > 0">
<span> Dismissed by {{ notice.dismissed_by.length }} servers </span>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EditIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, XIcon } from "@modrinth/assets";
import { EditIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
CopyCode,
injectNotificationManager,
NewModal,
ServerNotice,
TagItem,
TeleportDropdownMenu,
Toggle,
useRelativeTime,
} from "@modrinth/ui";
import { NOTICE_LEVELS } from "@modrinth/ui/src/utils/notices.ts";
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
import { useVIntl } from "@vintl/vintl";
import dayjs from "dayjs";
import { computed } from "vue";
import AssignNoticeModal from "~/components/ui/servers/notice/AssignNoticeModal.vue";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
ButtonStyled,
commonMessages,
CopyCode,
injectNotificationManager,
NewModal,
ServerNotice,
TagItem,
TeleportDropdownMenu,
Toggle,
useRelativeTime,
} from '@modrinth/ui'
import { NOTICE_LEVELS } from '@modrinth/ui/src/utils/notices.ts'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
const formatRelativeTime = useRelativeTime();
import AssignNoticeModal from '~/components/ui/servers/notice/AssignNoticeModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const notices = ref<ServerNoticeType[]>([]);
const createNoticeModal = ref<InstanceType<typeof NewModal>>();
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>();
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
await refreshNotices();
const notices = ref<ServerNoticeType[]>([])
const createNoticeModal = ref<InstanceType<typeof NewModal>>()
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
await refreshNotices()
async function refreshNotices() {
await useServersFetch("notices").then((res) => {
notices.value = res as ServerNoticeType[];
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at));
if (dateDiff === 0) {
return b.id - a.id;
}
await useServersFetch('notices').then((res) => {
notices.value = res as ServerNoticeType[]
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
if (dateDiff === 0) {
return b.id - a.id
}
return dateDiff;
});
});
return dateDiff
})
})
}
const levelOptions = Object.keys(NOTICE_LEVELS).map((x) => ({
id: x,
...NOTICE_LEVELS[x],
}));
id: x,
...NOTICE_LEVELS[x],
}))
const DATE_TIME_FORMAT = "YYYY-MM-DDTHH:mm";
const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm'
const newNoticeLevel = ref(levelOptions[0]);
const newNoticeDismissable = ref(false);
const newNoticeMessage = ref("");
const newNoticeScheduledDate = ref<string>();
const newNoticeTitle = ref<string>();
const newNoticeExpiresDate = ref<string>();
const newNoticeLevel = ref(levelOptions[0])
const newNoticeDismissable = ref(false)
const newNoticeMessage = ref('')
const newNoticeScheduledDate = ref<string>()
const newNoticeTitle = ref<string>()
const newNoticeExpiresDate = ref<string>()
function openNewNoticeModal() {
newNoticeLevel.value = levelOptions[0];
newNoticeDismissable.value = false;
newNoticeMessage.value = "";
newNoticeScheduledDate.value = undefined;
newNoticeExpiresDate.value = undefined;
editingNotice.value = undefined;
createNoticeModal.value?.show();
newNoticeLevel.value = levelOptions[0]
newNoticeDismissable.value = false
newNoticeMessage.value = ''
newNoticeScheduledDate.value = undefined
newNoticeExpiresDate.value = undefined
editingNotice.value = undefined
createNoticeModal.value?.show()
}
const editingNotice = ref<undefined | ServerNoticeType>();
const editingNotice = ref<undefined | ServerNoticeType>()
function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0];
newNoticeDismissable.value = notice.dismissable;
newNoticeMessage.value = notice.message;
newNoticeTitle.value = notice.title;
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT);
newNoticeExpiresDate.value = notice.expires
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
: undefined;
editingNotice.value = notice;
if (assignments) {
assignNoticeModal.value?.show?.(notice);
} else {
createNoticeModal.value?.show();
}
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0]
newNoticeDismissable.value = notice.dismissable
newNoticeMessage.value = notice.message
newNoticeTitle.value = notice.title
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT)
newNoticeExpiresDate.value = notice.expires
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
: undefined
editingNotice.value = notice
if (assignments) {
assignNoticeModal.value?.show?.(notice)
} else {
createNoticeModal.value?.show()
}
}
async function deleteNotice(notice: ServerNoticeType) {
await useServersFetch(`notices/${notice.id}`, {
method: "DELETE",
})
.then(() => {
addNotification({
title: `Successfully deleted notice #${notice.id}`,
type: "success",
});
})
.catch((err) => {
addNotification({
title: "Error deleting notice",
text: err,
type: "error",
});
});
await refreshNotices();
await useServersFetch(`notices/${notice.id}`, {
method: 'DELETE',
})
.then(() => {
addNotification({
title: `Successfully deleted notice #${notice.id}`,
type: 'success',
})
})
.catch((err) => {
addNotification({
title: 'Error deleting notice',
text: err,
type: 'error',
})
})
await refreshNotices()
}
const trimmedMessage = computed(() => newNoticeMessage.value?.trim());
const trimmedTitle = computed(() => newNoticeTitle.value?.trim());
const newNoticeSurvey = computed(() => newNoticeLevel.value.id === "survey");
const trimmedMessage = computed(() => newNoticeMessage.value?.trim())
const trimmedTitle = computed(() => newNoticeTitle.value?.trim())
const newNoticeSurvey = computed(() => newNoticeLevel.value.id === 'survey')
const noticeSubmitError = computed(() => {
let error: undefined | string;
if (!trimmedMessage.value || trimmedMessage.value.length === 0) {
error = "Notice message is required";
}
if (!newNoticeLevel.value) {
error = "Notice level is required";
}
return error;
});
let error: undefined | string
if (!trimmedMessage.value || trimmedMessage.value.length === 0) {
error = 'Notice message is required'
}
if (!newNoticeLevel.value) {
error = 'Notice level is required'
}
return error
})
function validateSubmission(message: string) {
if (noticeSubmitError.value) {
addNotification({
title: message,
text: noticeSubmitError.value,
type: "error",
});
return false;
}
return true;
if (noticeSubmitError.value) {
addNotification({
title: message,
text: noticeSubmitError.value,
type: 'error',
})
return false
}
return true
}
async function saveChanges() {
if (!validateSubmission("Error saving notice")) {
return;
}
if (!validateSubmission('Error saving notice')) {
return
}
await useServersFetch(`notices/${editingNotice.value?.id}`, {
method: "PATCH",
body: {
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value
? dayjs(newNoticeScheduledDate.value).toISOString()
: dayjs().toISOString(),
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: "Error saving changes to notice",
text: err,
type: "error",
});
});
await refreshNotices();
createNoticeModal.value?.hide();
await useServersFetch(`notices/${editingNotice.value?.id}`, {
method: 'PATCH',
body: {
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value
? dayjs(newNoticeScheduledDate.value).toISOString()
: dayjs().toISOString(),
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error saving changes to notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
async function createNotice() {
if (!validateSubmission("Error creating notice")) {
return;
}
if (!validateSubmission('Error creating notice')) {
return
}
await useServersFetch("notices", {
method: "POST",
body: {
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value
? dayjs(newNoticeScheduledDate.value).toISOString()
: dayjs().toISOString(),
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: "Error creating notice",
text: err,
type: "error",
});
});
await refreshNotices();
createNoticeModal.value?.hide();
await useServersFetch('notices', {
method: 'POST',
body: {
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value
? dayjs(newNoticeScheduledDate.value).toISOString()
: dayjs().toISOString(),
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error creating notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
const messages = defineMessages({
createNotice: {
id: "servers.notices.create-notice",
defaultMessage: "Create notice",
},
noNotices: {
id: "servers.notices.no-notices",
defaultMessage: "No notices",
},
dismissable: {
id: "servers.notice.dismissable",
defaultMessage: "Dismissable",
},
undismissable: {
id: "servers.notice.undismissable",
defaultMessage: "Undismissable",
},
id: {
id: "servers.notice.id",
defaultMessage: "ID",
},
begins: {
id: "servers.notice.begins",
defaultMessage: "Begins",
},
expires: {
id: "servers.notice.expires",
defaultMessage: "Expires",
},
actions: {
id: "servers.notice.actions",
defaultMessage: "Actions",
},
level: {
id: "servers.notice.level",
defaultMessage: "Level",
},
});
createNotice: {
id: 'servers.notices.create-notice',
defaultMessage: 'Create notice',
},
noNotices: {
id: 'servers.notices.no-notices',
defaultMessage: 'No notices',
},
dismissable: {
id: 'servers.notice.dismissable',
defaultMessage: 'Dismissable',
},
undismissable: {
id: 'servers.notice.undismissable',
defaultMessage: 'Undismissable',
},
id: {
id: 'servers.notice.id',
defaultMessage: 'ID',
},
begins: {
id: 'servers.notice.begins',
defaultMessage: 'Begins',
},
expires: {
id: 'servers.notice.expires',
defaultMessage: 'Expires',
},
actions: {
id: 'servers.notice.actions',
defaultMessage: 'Actions',
},
level: {
id: 'servers.notice.level',
defaultMessage: 'Level',
},
})
</script>
<style lang="scss" scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 78.5rem;
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 78.5rem;
}
</style>

View File

@@ -1,62 +1,62 @@
<template>
<div class="normal-page no-sidebar">
<h1>User account request</h1>
<div class="normal-page__content">
<div class="card flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
User email
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="userEmail"
type="email"
maxlength="64"
:placeholder="`Enter user email...`"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="getUserFromEmail">
<MailIcon aria-hidden="true" />
Get user account
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="normal-page no-sidebar">
<h1>User account request</h1>
<div class="normal-page__content">
<div class="card flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
User email
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="userEmail"
type="email"
maxlength="64"
:placeholder="`Enter user email...`"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="getUserFromEmail">
<MailIcon aria-hidden="true" />
Get user account
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MailIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager } from "@modrinth/ui";
import { MailIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const { addNotification } = injectNotificationManager()
const userEmail = ref("");
const userEmail = ref('')
async function getUserFromEmail() {
startLoading();
startLoading()
try {
const result = await useBaseFetch(`user_email?email=${encodeURIComponent(userEmail.value)}`, {
method: "GET",
apiVersion: 3,
});
try {
const result = await useBaseFetch(`user_email?email=${encodeURIComponent(userEmail.value)}`, {
method: 'GET',
apiVersion: 3,
})
await navigateTo(`/user/${result.username}`);
} catch (err) {
console.error(err);
addNotification({
title: "An error occurred",
text: err.data.description,
type: "error",
});
}
stopLoading();
await navigateTo(`/user/${result.username}`)
} catch (err) {
console.error(err)
addNotification({
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>

File diff suppressed because one or more lines are too long

View File

@@ -1,102 +1,102 @@
<script setup lang="ts">
definePageMeta({
middleware: ["launcher-auth"],
});
middleware: ['launcher-auth'],
})
</script>
<template>
<NuxtPage class="auth-container universal-card" />
<NuxtPage class="auth-container universal-card" />
</template>
<style>
.auth-container {
width: 26rem;
max-width: calc(100% - 2rem);
margin: 1rem auto;
display: flex;
flex-direction: column;
gap: 2rem;
width: 26rem;
max-width: calc(100% - 2rem);
margin: 1rem auto;
display: flex;
flex-direction: column;
gap: 2rem;
}
.auth-container h1 {
font-size: var(--font-size-xl);
margin: 0 0 -1rem 0;
color: var(--color-contrast);
font-size: var(--font-size-xl);
margin: 0 0 -1rem 0;
color: var(--color-contrast);
}
.auth-container p {
margin: 0;
margin: 0;
}
.auth-container .btn {
font-weight: 700;
min-height: 2.5rem;
text-decoration: none;
font-weight: 700;
min-height: 2.5rem;
text-decoration: none;
}
.centered-btn {
margin-inline: auto;
margin-inline: auto;
}
.btn.continue-btn svg {
margin: 0 0 0 0.5rem;
margin: 0 0 0 0.5rem;
}
.third-party {
display: grid;
gap: var(--gap-md);
grid-template-columns: repeat(2, 1fr);
width: 100%;
display: grid;
gap: var(--gap-md);
grid-template-columns: repeat(2, 1fr);
width: 100%;
}
.third-party .btn {
width: 100%;
vertical-align: middle;
width: 100%;
vertical-align: middle;
}
.third-party .btn svg {
margin-right: var(--gap-sm);
width: 1.25rem;
height: 1.25rem;
margin-right: var(--gap-sm);
width: 1.25rem;
height: 1.25rem;
}
@media screen and (max-width: 25.5rem) {
.third-party .btn {
grid-column: 1 / 3;
}
.third-party .btn {
grid-column: 1 / 3;
}
}
.auth-form {
display: flex;
flex-direction: column;
gap: var(--gap-md);
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.auth-form .auth-form__input {
width: 100%;
flex-basis: auto;
width: 100%;
flex-basis: auto;
}
.auth-form__additional-options {
align-items: center;
display: flex;
justify-content: center;
gap: var(--gap-md);
flex-wrap: wrap;
align-items: center;
display: flex;
justify-content: center;
gap: var(--gap-md);
flex-wrap: wrap;
}
.turnstile {
display: flex;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-md);
border: 2px solid var(--color-button-bg);
height: 65px;
width: 100%;
display: flex;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-md);
border: 2px solid var(--color-button-bg);
height: 65px;
width: 100%;
> div {
position: relative;
top: -2px;
min-width: calc(100% + 4px);
}
> div {
position: relative;
top: -2px;
min-width: calc(100% + 4px);
}
}
</style>

View File

@@ -1,362 +1,360 @@
<template>
<div>
<div v-if="error" class="oauth-items">
<div>
<h1>{{ formatMessage(commonMessages.errorLabel) }}</h1>
</div>
<p>
<span>{{ error.data.error }}: </span>
{{ error.data.description }}
</p>
</div>
<div v-else class="oauth-items">
<div class="connected-items">
<div class="profile-pics">
<Avatar size="md" :src="app.icon_url" />
<!-- <img class="profile-pic" :src="app.icon_url" alt="User profile picture" /> -->
<div class="connection-indicator"></div>
<Avatar size="md" circle :src="auth.user.avatar_url" />
<!-- <img class="profile-pic" :src="auth.user.avatar_url" alt="User profile picture" /> -->
</div>
</div>
<div class="title">
<h1>{{ formatMessage(messages.title, { appName: app.name }) }}</h1>
</div>
<div class="auth-info">
<div class="scope-heading">
<IntlFormatted
:message-id="messages.appInfo"
:values="{
appName: app.name,
creator: createdBy.username,
}"
>
<template #strong="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
<template #creator-link="{ children }">
<nuxt-link class="text-link" :to="'/user/' + createdBy.id">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</div>
<div class="scope-items">
<div v-for="scopeItem in scopeDefinitions" :key="scopeItem">
<div class="scope-item">
<div class="scope-icon">
<CheckIcon />
</div>
{{ scopeItem }}
</div>
</div>
</div>
</div>
<div class="button-row">
<Button class="wide-button" large :action="onReject" :disabled="pending">
<XIcon />
{{ formatMessage(messages.decline) }}
</Button>
<Button class="wide-button" color="primary" large :action="onAuthorize" :disabled="pending">
<CheckIcon />
{{ formatMessage(messages.authorize) }}
</Button>
</div>
<div class="redirection-notice">
<p class="redirect-instructions">
<IntlFormatted :message-id="messages.redirectUrl" :values="{ url: redirectUri }">
<template #redirect-url="{ children }">
<span class="redirect-url">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</p>
</div>
</div>
</div>
<div>
<div v-if="error" class="oauth-items">
<div>
<h1>{{ formatMessage(commonMessages.errorLabel) }}</h1>
</div>
<p>
<span>{{ error.data.error }}: </span>
{{ error.data.description }}
</p>
</div>
<div v-else class="oauth-items">
<div class="connected-items">
<div class="profile-pics">
<Avatar size="md" :src="app.icon_url" />
<!-- <img class="profile-pic" :src="app.icon_url" alt="User profile picture" /> -->
<div class="connection-indicator"></div>
<Avatar size="md" circle :src="auth.user.avatar_url" />
<!-- <img class="profile-pic" :src="auth.user.avatar_url" alt="User profile picture" /> -->
</div>
</div>
<div class="title">
<h1>{{ formatMessage(messages.title, { appName: app.name }) }}</h1>
</div>
<div class="auth-info">
<div class="scope-heading">
<IntlFormatted
:message-id="messages.appInfo"
:values="{
appName: app.name,
creator: createdBy.username,
}"
>
<template #strong="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
<template #creator-link="{ children }">
<nuxt-link class="text-link" :to="'/user/' + createdBy.id">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</div>
<div class="scope-items">
<div v-for="scopeItem in scopeDefinitions" :key="scopeItem">
<div class="scope-item">
<div class="scope-icon">
<CheckIcon />
</div>
{{ scopeItem }}
</div>
</div>
</div>
</div>
<div class="button-row">
<Button class="wide-button" large :action="onReject" :disabled="pending">
<XIcon />
{{ formatMessage(messages.decline) }}
</Button>
<Button class="wide-button" color="primary" large :action="onAuthorize" :disabled="pending">
<CheckIcon />
{{ formatMessage(messages.authorize) }}
</Button>
</div>
<div class="redirection-notice">
<p class="redirect-instructions">
<IntlFormatted :message-id="messages.redirectUrl" :values="{ url: redirectUri }">
<template #redirect-url="{ children }">
<span class="redirect-url">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { CheckIcon, XIcon } from "@modrinth/assets";
import { Avatar, Button, commonMessages, injectNotificationManager } from "@modrinth/ui";
import { useAuth } from "@/composables/auth.js";
import { useBaseFetch } from "@/composables/fetch.js";
import { CheckIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { useScopes } from "@/composables/auth/scopes.ts";
import { useAuth } from '@/composables/auth.js'
import { useScopes } from '@/composables/auth/scopes.ts'
import { useBaseFetch } from '@/composables/fetch.js'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
appInfo: {
id: "auth.authorize.app-info",
defaultMessage:
"<strong>{appName}</strong> by <creator-link>{creator}</creator-link> will be able to:",
},
authorize: {
id: "auth.authorize.action.authorize",
defaultMessage: "Authorize",
},
decline: {
id: "auth.authorize.action.decline",
defaultMessage: "Decline",
},
noRedirectUrlError: {
id: "auth.authorize.error.no-redirect-url",
defaultMessage: "No redirect location found in response",
},
redirectUrl: {
id: "auth.authorize.redirect-url",
defaultMessage: "You will be redirected to <redirect-url>{url}</redirect-url>",
},
title: {
id: "auth.authorize.authorize-app-name",
defaultMessage: "Authorize {appName}",
},
});
appInfo: {
id: 'auth.authorize.app-info',
defaultMessage:
'<strong>{appName}</strong> by <creator-link>{creator}</creator-link> will be able to:',
},
authorize: {
id: 'auth.authorize.action.authorize',
defaultMessage: 'Authorize',
},
decline: {
id: 'auth.authorize.action.decline',
defaultMessage: 'Decline',
},
noRedirectUrlError: {
id: 'auth.authorize.error.no-redirect-url',
defaultMessage: 'No redirect location found in response',
},
redirectUrl: {
id: 'auth.authorize.redirect-url',
defaultMessage: 'You will be redirected to <redirect-url>{url}</redirect-url>',
},
title: {
id: 'auth.authorize.authorize-app-name',
defaultMessage: 'Authorize {appName}',
},
})
const router = useNativeRoute();
const auth = await useAuth();
const { scopesToDefinitions } = useScopes();
const router = useNativeRoute()
const auth = await useAuth()
const { scopesToDefinitions } = useScopes()
const clientId = router.query?.client_id || false;
const redirectUri = router.query?.redirect_uri || false;
const scope = router.query?.scope || false;
const state = router.query?.state || false;
const clientId = router.query?.client_id || false
const redirectUri = router.query?.redirect_uri || false
const scope = router.query?.scope || false
const state = router.query?.state || false
const getFlowIdAuthorization = async () => {
const query = {
client_id: clientId,
redirect_uri: redirectUri,
scope,
};
if (state) {
query.state = state;
}
const query = {
client_id: clientId,
redirect_uri: redirectUri,
scope,
}
if (state) {
query.state = state
}
const authorization = await useBaseFetch("oauth/authorize", {
method: "GET",
internal: true,
query,
}); // This will contain the flow_id and oauth_client_id for accepting the oauth on behalf of the user
const authorization = await useBaseFetch('oauth/authorize', {
method: 'GET',
internal: true,
query,
}) // This will contain the flow_id and oauth_client_id for accepting the oauth on behalf of the user
if (typeof authorization === "string") {
await navigateTo(authorization, {
external: true,
});
}
if (typeof authorization === 'string') {
await navigateTo(authorization, {
external: true,
})
}
return authorization;
};
return authorization
}
const {
data: authorizationData,
pending,
error,
} = await useAsyncData("authorization", getFlowIdAuthorization);
data: authorizationData,
pending,
error,
} = await useAsyncData('authorization', getFlowIdAuthorization)
const { data: app } = await useAsyncData("oauth/app/" + clientId, () =>
useBaseFetch("oauth/app/" + clientId, {
method: "GET",
internal: true,
}),
);
const { data: app } = await useAsyncData('oauth/app/' + clientId, () =>
useBaseFetch('oauth/app/' + clientId, {
method: 'GET',
internal: true,
}),
)
const scopeDefinitions = scopesToDefinitions(
BigInt(authorizationData.value?.requested_scopes || 0),
);
const scopeDefinitions = scopesToDefinitions(BigInt(authorizationData.value?.requested_scopes || 0))
const { data: createdBy } = await useAsyncData("user/" + app.value.created_by, () =>
useBaseFetch("user/" + app.value.created_by, {
method: "GET",
apiVersion: 3,
}),
);
const { data: createdBy } = await useAsyncData('user/' + app.value.created_by, () =>
useBaseFetch('user/' + app.value.created_by, {
method: 'GET',
apiVersion: 3,
}),
)
const onAuthorize = async () => {
try {
const res = await useBaseFetch("oauth/accept", {
method: "POST",
internal: true,
body: {
flow: authorizationData.value.flow_id,
},
});
try {
const res = await useBaseFetch('oauth/accept', {
method: 'POST',
internal: true,
body: {
flow: authorizationData.value.flow_id,
},
})
if (typeof res === "string") {
navigateTo(res, {
external: true,
});
return;
}
if (typeof res === 'string') {
navigateTo(res, {
external: true,
})
return
}
throw new Error(formatMessage(messages.noRedirectUrlError));
} catch {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
}
};
throw new Error(formatMessage(messages.noRedirectUrlError))
} catch {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const onReject = async () => {
try {
const res = await useBaseFetch("oauth/reject", {
method: "POST",
body: {
flow: authorizationData.value.flow_id,
},
});
try {
const res = await useBaseFetch('oauth/reject', {
method: 'POST',
body: {
flow: authorizationData.value.flow_id,
},
})
if (typeof res === "string") {
navigateTo(res, {
external: true,
});
return;
}
if (typeof res === 'string') {
navigateTo(res, {
external: true,
})
return
}
throw new Error(formatMessage(messages.noRedirectUrlError));
} catch {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
}
};
throw new Error(formatMessage(messages.noRedirectUrlError))
} catch {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
</script>
<style scoped lang="scss">
.oauth-items {
display: flex;
flex-direction: column;
gap: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-xl);
}
.scope-items {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.scope-item {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
}
.scope-icon {
display: flex;
display: flex;
color: var(--color-raised-bg);
background-color: var(--color-green);
aspect-ratio: 1;
border-radius: 50%;
padding: var(--gap-xs);
color: var(--color-raised-bg);
background-color: var(--color-green);
aspect-ratio: 1;
border-radius: 50%;
padding: var(--gap-xs);
}
.title {
margin-inline: auto;
margin-inline: auto;
h1 {
margin-bottom: 0 !important;
}
h1 {
margin-bottom: 0 !important;
}
}
.redirection-notice {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
text-align: center;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
text-align: center;
.redirect-instructions {
font-size: var(--font-size-sm);
}
.redirect-instructions {
font-size: var(--font-size-sm);
}
.redirect-url {
font-weight: bold;
}
.redirect-url {
font-weight: bold;
}
}
.wide-button {
width: 100% !important;
width: 100% !important;
}
.button-row {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
justify-content: center;
display: flex;
flex-direction: row;
gap: var(--gap-xs);
justify-content: center;
}
.auth-info {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.scope-heading {
margin-bottom: var(--gap-sm);
margin-bottom: var(--gap-sm);
}
.profile-pics {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
.connection-indicator {
// Make sure the text sits in the middle and is centered.
// Make the text large, and make sure it's not selectable.
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
user-select: none;
.connection-indicator {
// Make sure the text sits in the middle and is centered.
// Make the text large, and make sure it's not selectable.
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
user-select: none;
color: var(--color-primary);
}
color: var(--color-primary);
}
}
.profile-pic {
width: 6rem;
height: 6rem;
border-radius: 50%;
margin: 0 1rem;
width: 6rem;
height: 6rem;
border-radius: 50%;
margin: 0 1rem;
}
.dotted-border-line {
width: 75%;
border: 0.1rem dashed var(--color-divider);
width: 75%;
border: 0.1rem dashed var(--color-divider);
}
.connected-items {
// Display dotted-border-line under profile-pics and centered behind them
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
margin-top: 1rem;
// Display dotted-border-line under profile-pics and centered behind them
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
margin-top: 1rem;
// Display profile-pics on top of dotted-border-line
.profile-pics {
position: relative;
z-index: 2;
}
// Display profile-pics on top of dotted-border-line
.profile-pics {
position: relative;
z-index: 2;
}
// Display dotted-border-line behind profile-pics
.dotted-border-line {
position: absolute;
z-index: 1;
}
// Display dotted-border-line behind profile-pics
.dotted-border-line {
position: absolute;
z-index: 1;
}
}
</style>

View File

@@ -1,228 +1,229 @@
<template>
<div>
<h1>{{ formatMessage(messages.longTitle) }}</h1>
<section class="auth-form">
<template v-if="step === 'choose_method'">
<p>
{{ formatMessage(methodChoiceMessages.description) }}
</p>
<div>
<h1>{{ formatMessage(messages.longTitle) }}</h1>
<section class="auth-form">
<template v-if="step === 'choose_method'">
<p>
{{ formatMessage(methodChoiceMessages.description) }}
</p>
<div class="iconified-input">
<label for="email" hidden>
{{ formatMessage(methodChoiceMessages.emailUsernameLabel) }}
</label>
<MailIcon />
<input
id="email"
v-model="email"
type="text"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(methodChoiceMessages.emailUsernamePlaceholder)"
/>
</div>
<div class="iconified-input">
<label for="email" hidden>
{{ formatMessage(methodChoiceMessages.emailUsernameLabel) }}
</label>
<MailIcon />
<input
id="email"
v-model="email"
type="text"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(methodChoiceMessages.emailUsernamePlaceholder)"
/>
</div>
<HCaptcha ref="captcha" v-model="token" />
<HCaptcha ref="captcha" v-model="token" />
<button class="btn btn-primary centered-btn" :disabled="!token" @click="recovery">
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
</button>
</template>
<template v-else-if="step === 'passed_challenge'">
<p>{{ formatMessage(postChallengeMessages.description) }}</p>
<button class="btn btn-primary centered-btn" :disabled="!token" @click="recovery">
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
</button>
</template>
<template v-else-if="step === 'passed_challenge'">
<p>{{ formatMessage(postChallengeMessages.description) }}</p>
<div class="iconified-input">
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<KeyIcon />
<input
id="password"
v-model="newPassword"
type="password"
autocomplete="new-password"
class="auth-form__input"
:placeholder="formatMessage(commonMessages.passwordLabel)"
/>
</div>
<div class="iconified-input">
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<KeyIcon />
<input
id="password"
v-model="newPassword"
type="password"
autocomplete="new-password"
class="auth-form__input"
:placeholder="formatMessage(commonMessages.passwordLabel)"
/>
</div>
<div class="iconified-input">
<label for="confirm-password" hidden>
{{ formatMessage(commonMessages.passwordLabel) }}
</label>
<KeyIcon />
<input
id="confirm-password"
v-model="confirmNewPassword"
type="password"
autocomplete="new-password"
class="auth-form__input"
:placeholder="formatMessage(postChallengeMessages.confirmPasswordLabel)"
/>
</div>
<div class="iconified-input">
<label for="confirm-password" hidden>
{{ formatMessage(commonMessages.passwordLabel) }}
</label>
<KeyIcon />
<input
id="confirm-password"
v-model="confirmNewPassword"
type="password"
autocomplete="new-password"
class="auth-form__input"
:placeholder="formatMessage(postChallengeMessages.confirmPasswordLabel)"
/>
</div>
<button class="auth-form__input btn btn-primary continue-btn" @click="changePassword">
{{ formatMessage(postChallengeMessages.action) }}
</button>
</template>
</section>
</div>
<button class="auth-form__input btn btn-primary continue-btn" @click="changePassword">
{{ formatMessage(postChallengeMessages.action) }}
</button>
</template>
</section>
</div>
</template>
<script setup>
import { KeyIcon, MailIcon, SendIcon } from "@modrinth/assets";
import { commonMessages, injectNotificationManager } from "@modrinth/ui";
import HCaptcha from "@/components/ui/HCaptcha.vue";
import { KeyIcon, MailIcon, SendIcon } from '@modrinth/assets'
import { commonMessages, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
import HCaptcha from '@/components/ui/HCaptcha.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const methodChoiceMessages = defineMessages({
description: {
id: "auth.reset-password.method-choice.description",
defaultMessage:
"Enter your email below and we'll send a recovery link to allow you to recover your account.",
},
emailUsernameLabel: {
id: "auth.reset-password.method-choice.email-username.label",
defaultMessage: "Email or username",
},
emailUsernamePlaceholder: {
id: "auth.reset-password.method-choice.email-username.placeholder",
defaultMessage: "Email",
},
action: {
id: "auth.reset-password.method-choice.action",
defaultMessage: "Send recovery email",
},
});
description: {
id: 'auth.reset-password.method-choice.description',
defaultMessage:
"Enter your email below and we'll send a recovery link to allow you to recover your account.",
},
emailUsernameLabel: {
id: 'auth.reset-password.method-choice.email-username.label',
defaultMessage: 'Email or username',
},
emailUsernamePlaceholder: {
id: 'auth.reset-password.method-choice.email-username.placeholder',
defaultMessage: 'Email',
},
action: {
id: 'auth.reset-password.method-choice.action',
defaultMessage: 'Send recovery email',
},
})
const postChallengeMessages = defineMessages({
description: {
id: "auth.reset-password.post-challenge.description",
defaultMessage: "Enter your new password below to gain access to your account.",
},
confirmPasswordLabel: {
id: "auth.reset-password.post-challenge.confirm-password.label",
defaultMessage: "Confirm password",
},
action: {
id: "auth.reset-password.post-challenge.action",
defaultMessage: "Reset password",
},
});
description: {
id: 'auth.reset-password.post-challenge.description',
defaultMessage: 'Enter your new password below to gain access to your account.',
},
confirmPasswordLabel: {
id: 'auth.reset-password.post-challenge.confirm-password.label',
defaultMessage: 'Confirm password',
},
action: {
id: 'auth.reset-password.post-challenge.action',
defaultMessage: 'Reset password',
},
})
// NOTE(Brawaru): Vite uses esbuild for minification so can't combine these
// because it'll keep the original prop names compared to consts, which names
// will be mangled.
const emailSentNotificationMessages = defineMessages({
title: {
id: "auth.reset-password.notification.email-sent.title",
defaultMessage: "Email sent",
},
text: {
id: "auth.reset-password.notification.email-sent.text",
defaultMessage:
"An email with instructions has been sent to you if the email was previously saved on your account.",
},
});
title: {
id: 'auth.reset-password.notification.email-sent.title',
defaultMessage: 'Email sent',
},
text: {
id: 'auth.reset-password.notification.email-sent.text',
defaultMessage:
'An email with instructions has been sent to you if the email was previously saved on your account.',
},
})
const passwordResetNotificationMessages = defineMessages({
title: {
id: "auth.reset-password.notification.password-reset.title",
defaultMessage: "Password successfully reset",
},
text: {
id: "auth.reset-password.notification.password-reset.text",
defaultMessage: "You can now log-in into your account with your new password.",
},
});
title: {
id: 'auth.reset-password.notification.password-reset.title',
defaultMessage: 'Password successfully reset',
},
text: {
id: 'auth.reset-password.notification.password-reset.text',
defaultMessage: 'You can now log-in into your account with your new password.',
},
})
const messages = defineMessages({
title: {
id: "auth.reset-password.title",
defaultMessage: "Reset Password",
},
longTitle: {
id: "auth.reset-password.title.long",
defaultMessage: "Reset your password",
},
});
title: {
id: 'auth.reset-password.title',
defaultMessage: 'Reset Password',
},
longTitle: {
id: 'auth.reset-password.title.long',
defaultMessage: 'Reset your password',
},
})
useHead({
title: () => `${formatMessage(messages.title)} - Modrinth`,
});
title: () => `${formatMessage(messages.title)} - Modrinth`,
})
const auth = await useAuth();
const auth = await useAuth()
if (auth.value.user) {
await navigateTo("/dashboard");
await navigateTo('/dashboard')
}
const route = useNativeRoute();
const route = useNativeRoute()
const step = ref("choose_method");
const step = ref('choose_method')
if (route.query.flow) {
step.value = "passed_challenge";
step.value = 'passed_challenge'
}
const captcha = ref();
const captcha = ref()
const email = ref("");
const token = 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,
},
});
startLoading()
try {
await useBaseFetch('auth/password/reset', {
method: 'POST',
body: {
username: email.value,
challenge: token.value,
},
})
addNotification({
title: formatMessage(emailSentNotificationMessages.title),
text: formatMessage(emailSentNotificationMessages.text),
type: "success",
});
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
captcha.value?.reset();
}
stopLoading();
addNotification({
title: formatMessage(emailSentNotificationMessages.title),
text: formatMessage(emailSentNotificationMessages.text),
type: 'success',
})
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
captcha.value?.reset()
}
stopLoading()
}
const newPassword = ref("");
const confirmNewPassword = ref("");
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,
},
});
startLoading()
try {
await useBaseFetch('auth/password', {
method: 'PATCH',
body: {
new_password: newPassword.value,
flow: route.query.flow,
},
})
addNotification({
title: formatMessage(passwordResetNotificationMessages.title),
text: formatMessage(passwordResetNotificationMessages.text),
type: "success",
});
await navigateTo("/auth/sign-in");
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
captcha.value?.reset();
}
stopLoading();
addNotification({
title: formatMessage(passwordResetNotificationMessages.title),
text: formatMessage(passwordResetNotificationMessages.text),
type: 'success',
})
await navigateTo('/auth/sign-in')
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
captcha.value?.reset()
}
stopLoading()
}
</script>

View File

@@ -1,314 +1,315 @@
<template>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
</span>
</label>
<input
id="two-factor-code"
v-model="twoFactorCode"
maxlength="11"
type="text"
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
autocomplete="one-time-code"
autofocus
@keyup.enter="begin2FASignIn"
/>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
</span>
</label>
<input
id="two-factor-code"
v-model="twoFactorCode"
maxlength="11"
type="text"
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
autocomplete="one-time-code"
autofocus
@keyup.enter="begin2FASignIn"
/>
<button class="btn btn-primary continue-btn" @click="begin2FASignIn">
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
</template>
<template v-else>
<h1>{{ formatMessage(messages.signInWithLabel) }}</h1>
<button class="btn btn-primary continue-btn" @click="begin2FASignIn">
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
</template>
<template v-else>
<h1>{{ formatMessage(messages.signInWithLabel) }}</h1>
<section class="third-party">
<a class="btn" :href="getAuthUrl('discord', redirectTarget)">
<SSODiscordIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<SSOGitHubIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<SSOMicrosoftIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<SSOGoogleIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SSOSteamIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<SSOGitLabIcon />
<span>GitLab</span>
</a>
</section>
<section class="third-party">
<a class="btn" :href="getAuthUrl('discord', redirectTarget)">
<SSODiscordIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<SSOGitHubIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<SSOMicrosoftIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<SSOGoogleIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SSOSteamIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<SSOGitLabIcon />
<span>GitLab</span>
</a>
</section>
<h1>{{ formatMessage(messages.usePasswordLabel) }}</h1>
<h1>{{ formatMessage(messages.usePasswordLabel) }}</h1>
<section class="auth-form">
<div class="iconified-input">
<label for="email" hidden>{{ formatMessage(messages.emailUsernameLabel) }}</label>
<MailIcon />
<input
id="email"
v-model="email"
type="text"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(messages.emailUsernameLabel)"
/>
</div>
<section class="auth-form">
<div class="iconified-input">
<label for="email" hidden>{{ formatMessage(messages.emailUsernameLabel) }}</label>
<MailIcon />
<input
id="email"
v-model="email"
type="text"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(messages.emailUsernameLabel)"
/>
</div>
<div class="iconified-input">
<label for="password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
<KeyIcon />
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="auth-form__input"
:placeholder="formatMessage(messages.passwordLabel)"
/>
</div>
<div class="iconified-input">
<label for="password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
<KeyIcon />
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="auth-form__input"
:placeholder="formatMessage(messages.passwordLabel)"
/>
</div>
<HCaptcha ref="captcha" v-model="token" />
<HCaptcha ref="captcha" v-model="token" />
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="!token"
@click="beginPasswordSignIn()"
>
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="!token"
@click="beginPasswordSignIn()"
>
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
<div class="auth-form__additional-options">
<IntlFormatted :message-id="messages.additionalOptionsLabel">
<template #forgot-password-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
<template #create-account-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-up',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</div>
</section>
</template>
</div>
<div class="auth-form__additional-options">
<IntlFormatted :message-id="messages.additionalOptionsLabel">
<template #forgot-password-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
<template #create-account-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-up',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</div>
</section>
</template>
</div>
</template>
<script setup>
import {
KeyIcon,
MailIcon,
RightArrowIcon,
SSODiscordIcon,
SSOGitHubIcon,
SSOGitLabIcon,
SSOGoogleIcon,
SSOMicrosoftIcon,
SSOSteamIcon,
} from "@modrinth/assets";
import { commonMessages, injectNotificationManager } from "@modrinth/ui";
import HCaptcha from "@/components/ui/HCaptcha.vue";
KeyIcon,
MailIcon,
RightArrowIcon,
SSODiscordIcon,
SSOGitHubIcon,
SSOGitLabIcon,
SSOGoogleIcon,
SSOMicrosoftIcon,
SSOSteamIcon,
} from '@modrinth/assets'
import { commonMessages, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
import HCaptcha from '@/components/ui/HCaptcha.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
additionalOptionsLabel: {
id: "auth.sign-in.additional-options",
defaultMessage:
"<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>",
},
emailUsernameLabel: {
id: "auth.sign-in.email-username.label",
defaultMessage: "Email or username",
},
passwordLabel: {
id: "auth.sign-in.password.label",
defaultMessage: "Password",
},
signInWithLabel: {
id: "auth.sign-in.sign-in-with",
defaultMessage: "Sign in with",
},
signInTitle: {
id: "auth.sign-in.title",
defaultMessage: "Sign In",
},
twoFactorCodeInputPlaceholder: {
id: "auth.sign-in.2fa.placeholder",
defaultMessage: "Enter code...",
},
twoFactorCodeLabel: {
id: "auth.sign-in.2fa.label",
defaultMessage: "Enter two-factor code",
},
twoFactorCodeLabelDescription: {
id: "auth.sign-in.2fa.description",
defaultMessage: "Please enter a two-factor code to proceed.",
},
usePasswordLabel: {
id: "auth.sign-in.use-password",
defaultMessage: "Or use a password",
},
});
additionalOptionsLabel: {
id: 'auth.sign-in.additional-options',
defaultMessage:
'<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>',
},
emailUsernameLabel: {
id: 'auth.sign-in.email-username.label',
defaultMessage: 'Email or username',
},
passwordLabel: {
id: 'auth.sign-in.password.label',
defaultMessage: 'Password',
},
signInWithLabel: {
id: 'auth.sign-in.sign-in-with',
defaultMessage: 'Sign in with',
},
signInTitle: {
id: 'auth.sign-in.title',
defaultMessage: 'Sign In',
},
twoFactorCodeInputPlaceholder: {
id: 'auth.sign-in.2fa.placeholder',
defaultMessage: 'Enter code...',
},
twoFactorCodeLabel: {
id: 'auth.sign-in.2fa.label',
defaultMessage: 'Enter two-factor code',
},
twoFactorCodeLabelDescription: {
id: 'auth.sign-in.2fa.description',
defaultMessage: 'Please enter a two-factor code to proceed.',
},
usePasswordLabel: {
id: 'auth.sign-in.use-password',
defaultMessage: 'Or use a password',
},
})
useHead({
title() {
return `${formatMessage(messages.signInTitle)} - Modrinth`;
},
});
title() {
return `${formatMessage(messages.signInTitle)} - Modrinth`
},
})
const auth = await useAuth();
const route = useNativeRoute();
const auth = await useAuth()
const route = useNativeRoute()
const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
const redirectTarget = route.query.redirect || ''
const subtleLauncherRedirectUri = ref()
if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn();
if (route.query.code && !route.fullPath.includes('new_account=true')) {
await finishSignIn()
}
if (auth.value.user) {
await finishSignIn();
await finishSignIn()
}
const captcha = ref();
const captcha = ref()
const email = ref("");
const password = ref("");
const token = ref("");
const email = ref('')
const password = ref('')
const token = ref('')
const flow = ref(route.query.flow);
const flow = ref(route.query.flow)
async function beginPasswordSignIn() {
startLoading();
try {
const res = await useBaseFetch("auth/login", {
method: "POST",
body: {
username: email.value,
password: password.value,
challenge: token.value,
},
});
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 finishSignIn(res.session);
}
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
captcha.value?.reset();
}
stopLoading();
if (res.flow) {
flow.value = res.flow
} else {
await finishSignIn(res.session)
}
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
captcha.value?.reset()
}
stopLoading()
}
const twoFactorCode = ref(null);
const twoFactorCode = ref(null)
async function begin2FASignIn() {
startLoading();
try {
const res = await useBaseFetch("auth/login/2fa", {
method: "POST",
body: {
flow: flow.value,
code: twoFactorCode.value ? twoFactorCode.value.toString() : twoFactorCode.value,
},
});
startLoading()
try {
const res = await useBaseFetch('auth/login/2fa', {
method: 'POST',
body: {
flow: flow.value,
code: twoFactorCode.value ? twoFactorCode.value.toString() : twoFactorCode.value,
},
})
await finishSignIn(res.session);
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
captcha.value?.reset();
}
stopLoading();
await finishSignIn(res.session)
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
captcha.value?.reset()
}
stopLoading()
}
async function finishSignIn(token) {
if (route.query.launcher) {
if (!token) {
token = auth.value.token;
}
if (route.query.launcher) {
if (!token) {
token = auth.value.token
}
const usesLocalhostRedirectionScheme =
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
const usesLocalhostRedirectionScheme =
['4', '6'].includes(route.query.ipver) && Number(route.query.port) < 65536
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`;
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === '4' ? '127.0.0.1' : '[::1]'}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl;
} else {
await navigateTo(redirectUrl, {
external: true,
});
}
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl
} else {
await navigateTo(redirectUrl, {
external: true,
})
}
return;
}
return
}
if (token) {
await useAuth(token);
await useUser();
}
if (token) {
await useAuth(token)
await useUser()
}
if (route.query.redirect) {
const redirect = decodeURIComponent(route.query.redirect);
await navigateTo(redirect, {
replace: true,
});
} else {
await navigateTo("/dashboard");
}
if (route.query.redirect) {
const redirect = decodeURIComponent(route.query.redirect)
await navigateTo(redirect, {
replace: true,
})
} else {
await navigateTo('/dashboard')
}
}
</script>

View File

@@ -1,273 +1,274 @@
<template>
<div>
<h1>{{ formatMessage(messages.signUpWithTitle) }}</h1>
<div>
<h1>{{ formatMessage(messages.signUpWithTitle) }}</h1>
<section class="third-party">
<a class="btn discord-btn" :href="getAuthUrl('discord', redirectTarget)">
<SSODiscordIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<SSOGitHubIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<SSOMicrosoftIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<SSOGoogleIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SSOSteamIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<SSOGitLabIcon />
<span>GitLab</span>
</a>
</section>
<section class="third-party">
<a class="btn discord-btn" :href="getAuthUrl('discord', redirectTarget)">
<SSODiscordIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<SSOGitHubIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<SSOMicrosoftIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<SSOGoogleIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SSOSteamIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<SSOGitLabIcon />
<span>GitLab</span>
</a>
</section>
<h1>{{ formatMessage(messages.createAccountTitle) }}</h1>
<h1>{{ formatMessage(messages.createAccountTitle) }}</h1>
<section class="auth-form">
<div class="iconified-input">
<label for="email" hidden>{{ formatMessage(messages.emailLabel) }}</label>
<MailIcon />
<input
id="email"
v-model="email"
type="email"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(messages.emailLabel)"
/>
</div>
<section class="auth-form">
<div class="iconified-input">
<label for="email" hidden>{{ formatMessage(messages.emailLabel) }}</label>
<MailIcon />
<input
id="email"
v-model="email"
type="email"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(messages.emailLabel)"
/>
</div>
<div class="iconified-input">
<label for="username" hidden>{{ formatMessage(messages.usernameLabel) }}</label>
<UserIcon />
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(messages.usernameLabel)"
/>
</div>
<div class="iconified-input">
<label for="username" hidden>{{ formatMessage(messages.usernameLabel) }}</label>
<UserIcon />
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
class="auth-form__input"
:placeholder="formatMessage(messages.usernameLabel)"
/>
</div>
<div class="iconified-input">
<label for="password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
<KeyIcon />
<input
id="password"
v-model="password"
class="auth-form__input"
type="password"
autocomplete="new-password"
:placeholder="formatMessage(messages.passwordLabel)"
/>
</div>
<div class="iconified-input">
<label for="password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
<KeyIcon />
<input
id="password"
v-model="password"
class="auth-form__input"
type="password"
autocomplete="new-password"
:placeholder="formatMessage(messages.passwordLabel)"
/>
</div>
<div class="iconified-input">
<label for="confirm-password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
<KeyIcon />
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
class="auth-form__input"
:placeholder="formatMessage(messages.confirmPasswordLabel)"
/>
</div>
<div class="iconified-input">
<label for="confirm-password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
<KeyIcon />
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
class="auth-form__input"
:placeholder="formatMessage(messages.confirmPasswordLabel)"
/>
</div>
<Checkbox
v-model="subscribe"
class="subscribe-btn"
:label="formatMessage(messages.subscribeLabel)"
:description="formatMessage(messages.subscribeLabel)"
/>
<Checkbox
v-model="subscribe"
class="subscribe-btn"
:label="formatMessage(messages.subscribeLabel)"
:description="formatMessage(messages.subscribeLabel)"
/>
<p v-if="!route.query.launcher">
<IntlFormatted :message-id="messages.legalDisclaimer">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
<p v-if="!route.query.launcher">
<IntlFormatted :message-id="messages.legalDisclaimer">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
<HCaptcha ref="captcha" v-model="token" />
<HCaptcha ref="captcha" v-model="token" />
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="!token"
@click="createAccount"
>
{{ formatMessage(messages.createAccountButton) }} <RightArrowIcon />
</button>
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="!token"
@click="createAccount"
>
{{ formatMessage(messages.createAccountButton) }} <RightArrowIcon />
</button>
<div class="auth-form__additional-options">
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-in',
query: route.query,
}"
>
{{ formatMessage(commonMessages.signInButton) }}
</NuxtLink>
</div>
</section>
</div>
<div class="auth-form__additional-options">
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-in',
query: route.query,
}"
>
{{ formatMessage(commonMessages.signInButton) }}
</NuxtLink>
</div>
</section>
</div>
</template>
<script setup>
import {
KeyIcon,
MailIcon,
RightArrowIcon,
SSODiscordIcon,
SSOGitHubIcon,
SSOGitLabIcon,
SSOGoogleIcon,
SSOMicrosoftIcon,
SSOSteamIcon,
UserIcon,
} from "@modrinth/assets";
import { Checkbox, commonMessages, injectNotificationManager } from "@modrinth/ui";
import HCaptcha from "@/components/ui/HCaptcha.vue";
KeyIcon,
MailIcon,
RightArrowIcon,
SSODiscordIcon,
SSOGitHubIcon,
SSOGitLabIcon,
SSOGoogleIcon,
SSOMicrosoftIcon,
SSOSteamIcon,
UserIcon,
} from '@modrinth/assets'
import { Checkbox, commonMessages, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
import HCaptcha from '@/components/ui/HCaptcha.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: "auth.sign-up.title",
defaultMessage: "Sign Up",
},
signUpWithTitle: {
id: "auth.sign-up.title.sign-up-with",
defaultMessage: "Sign up with",
},
createAccountTitle: {
id: "auth.sign-up.title.create-account",
defaultMessage: "Or create an account yourself",
},
emailLabel: {
id: "auth.sign-up.email.label",
defaultMessage: "Email",
},
usernameLabel: {
id: "auth.sign-up.label.username",
defaultMessage: "Username",
},
passwordLabel: {
id: "auth.sign-up.password.label",
defaultMessage: "Password",
},
confirmPasswordLabel: {
id: "auth.sign-up.confirm-password.label",
defaultMessage: "Confirm password",
},
subscribeLabel: {
id: "auth.sign-up.subscribe.label",
defaultMessage: "Subscribe to updates about Modrinth",
},
legalDisclaimer: {
id: "auth.sign-up.legal-dislaimer",
defaultMessage:
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
createAccountButton: {
id: "auth.sign-up.action.create-account",
defaultMessage: "Create account",
},
alreadyHaveAccountLabel: {
id: "auth.sign-up.sign-in-option.title",
defaultMessage: "Already have an account?",
},
});
title: {
id: 'auth.sign-up.title',
defaultMessage: 'Sign Up',
},
signUpWithTitle: {
id: 'auth.sign-up.title.sign-up-with',
defaultMessage: 'Sign up with',
},
createAccountTitle: {
id: 'auth.sign-up.title.create-account',
defaultMessage: 'Or create an account yourself',
},
emailLabel: {
id: 'auth.sign-up.email.label',
defaultMessage: 'Email',
},
usernameLabel: {
id: 'auth.sign-up.label.username',
defaultMessage: 'Username',
},
passwordLabel: {
id: 'auth.sign-up.password.label',
defaultMessage: 'Password',
},
confirmPasswordLabel: {
id: 'auth.sign-up.confirm-password.label',
defaultMessage: 'Confirm password',
},
subscribeLabel: {
id: 'auth.sign-up.subscribe.label',
defaultMessage: 'Subscribe to updates about Modrinth',
},
legalDisclaimer: {
id: 'auth.sign-up.legal-dislaimer',
defaultMessage:
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
createAccountButton: {
id: 'auth.sign-up.action.create-account',
defaultMessage: 'Create account',
},
alreadyHaveAccountLabel: {
id: 'auth.sign-up.sign-in-option.title',
defaultMessage: 'Already have an account?',
},
})
useHead({
title: () => `${formatMessage(messages.title)} - Modrinth`,
});
title: () => `${formatMessage(messages.title)} - Modrinth`,
})
const auth = await useAuth();
const route = useNativeRoute();
const auth = await useAuth()
const route = useNativeRoute()
const redirectTarget = route.query.redirect;
const redirectTarget = route.query.redirect
if (auth.value.user) {
await navigateTo("/dashboard");
await navigateTo('/dashboard')
}
const captcha = ref();
const captcha = ref()
const email = ref("");
const username = ref("");
const password = ref("");
const confirmPassword = ref("");
const token = ref("");
const subscribe = ref(false);
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const token = ref('')
const subscribe = ref(false)
async function createAccount() {
startLoading();
try {
if (confirmPassword.value !== password.value) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage({
id: "auth.sign-up.notification.password-mismatch.text",
defaultMessage: "Passwords do not match!",
}),
type: "error",
});
captcha.value?.reset();
}
startLoading()
try {
if (confirmPassword.value !== password.value) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage({
id: 'auth.sign-up.notification.password-mismatch.text',
defaultMessage: 'Passwords do not match!',
}),
type: 'error',
})
captcha.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,
},
});
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();
await useAuth(res.session)
await useUser()
if (route.query.launcher) {
await navigateTo({ path: "/auth/sign-in", query: route.query });
return;
}
if (route.query.launcher) {
await navigateTo({ path: '/auth/sign-in', query: route.query })
return
}
if (route.query.redirect) {
await navigateTo(route.query.redirect);
} else {
await navigateTo("/dashboard");
}
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
captcha.value?.reset();
}
stopLoading();
if (route.query.redirect) {
await navigateTo(route.query.redirect)
} else {
await navigateTo('/dashboard')
}
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
captcha.value?.reset()
}
stopLoading()
}
</script>

View File

@@ -1,152 +1,152 @@
<template>
<div>
<template v-if="auth.user && auth.user.email_verified && !success">
<h1>{{ formatMessage(alreadyVerifiedMessages.title) }}</h1>
<div>
<template v-if="auth.user && auth.user.email_verified && !success">
<h1>{{ formatMessage(alreadyVerifiedMessages.title) }}</h1>
<section class="auth-form">
<p>{{ formatMessage(alreadyVerifiedMessages.description) }}</p>
<section class="auth-form">
<p>{{ formatMessage(alreadyVerifiedMessages.description) }}</p>
<NuxtLink class="btn" to="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
</section>
</template>
<NuxtLink class="btn" to="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
</section>
</template>
<template v-else-if="success">
<h1>{{ formatMessage(postVerificationMessages.title) }}</h1>
<template v-else-if="success">
<h1>{{ formatMessage(postVerificationMessages.title) }}</h1>
<section class="auth-form">
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
<section class="auth-form">
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
<NuxtLink v-if="auth.user" class="btn" link="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
</section>
</template>
<NuxtLink v-if="auth.user" class="btn" link="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
</section>
</template>
<template v-else>
<h1>{{ formatMessage(failedVerificationMessages.title) }}</h1>
<template v-else>
<h1>{{ formatMessage(failedVerificationMessages.title) }}</h1>
<section class="auth-form">
<p>
<template v-if="auth.user">
{{ formatMessage(failedVerificationMessages.loggedInDescription) }}
</template>
<template v-else>
{{ formatMessage(failedVerificationMessages.description) }}
</template>
</p>
<section class="auth-form">
<p>
<template v-if="auth.user">
{{ formatMessage(failedVerificationMessages.loggedInDescription) }}
</template>
<template v-else>
{{ formatMessage(failedVerificationMessages.description) }}
</template>
</p>
<button v-if="auth.user" class="btn btn-primary continue-btn" @click="resendVerifyEmail">
{{ formatMessage(failedVerificationMessages.action) }} <RightArrowIcon />
</button>
<button v-if="auth.user" class="btn btn-primary continue-btn" @click="resendVerifyEmail">
{{ formatMessage(failedVerificationMessages.action) }} <RightArrowIcon />
</button>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
</section>
</template>
</div>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
</section>
</template>
</div>
</template>
<script setup>
import { SettingsIcon, RightArrowIcon } from "@modrinth/assets";
import { RightArrowIcon, SettingsIcon } from '@modrinth/assets'
const { formatMessage } = useVIntl();
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: "auth.verify-email.title",
defaultMessage: "Verify Email",
},
accountSettings: {
id: "auth.verify-email.action.account-settings",
defaultMessage: "Account settings",
},
signIn: {
id: "auth.verify-email.action.sign-in",
defaultMessage: "Sign in",
},
});
title: {
id: 'auth.verify-email.title',
defaultMessage: 'Verify Email',
},
accountSettings: {
id: 'auth.verify-email.action.account-settings',
defaultMessage: 'Account settings',
},
signIn: {
id: 'auth.verify-email.action.sign-in',
defaultMessage: 'Sign in',
},
})
const alreadyVerifiedMessages = defineMessages({
title: {
id: "auth.verify-email.already-verified.title",
defaultMessage: "Email already verified",
},
description: {
id: "auth.verify-email.already-verified.description",
defaultMessage: "Your email is already verified!",
},
});
title: {
id: 'auth.verify-email.already-verified.title',
defaultMessage: 'Email already verified',
},
description: {
id: 'auth.verify-email.already-verified.description',
defaultMessage: 'Your email is already verified!',
},
})
const postVerificationMessages = defineMessages({
title: {
id: "auth.verify-email.post-verification.title",
defaultMessage: "Email verification",
},
description: {
id: "auth.verify-email.post-verification.description",
defaultMessage: "Your email address has been successfully verified!",
},
});
title: {
id: 'auth.verify-email.post-verification.title',
defaultMessage: 'Email verification',
},
description: {
id: 'auth.verify-email.post-verification.description',
defaultMessage: 'Your email address has been successfully verified!',
},
})
const failedVerificationMessages = defineMessages({
title: {
id: "auth.verify-email.failed-verification.title",
defaultMessage: "Email verification failed",
},
description: {
id: "auth.verify-email.failed-verification.description",
defaultMessage:
"We were unable to verify your email. Try re-sending the verification email through your dashboard by signing in.",
},
loggedInDescription: {
id: "auth.verify-email.failed-verification.description.logged-in",
defaultMessage:
"We were unable to verify your email. Try re-sending the verification email through the button below.",
},
action: {
id: "auth.verify-email.failed-verification.action",
defaultMessage: "Resend verification email",
},
});
title: {
id: 'auth.verify-email.failed-verification.title',
defaultMessage: 'Email verification failed',
},
description: {
id: 'auth.verify-email.failed-verification.description',
defaultMessage:
'We were unable to verify your email. Try re-sending the verification email through your dashboard by signing in.',
},
loggedInDescription: {
id: 'auth.verify-email.failed-verification.description.logged-in',
defaultMessage:
'We were unable to verify your email. Try re-sending the verification email through the button below.',
},
action: {
id: 'auth.verify-email.failed-verification.action',
defaultMessage: 'Resend verification email',
},
})
useHead({
title: () => `${formatMessage(messages.title)} - Modrinth`,
});
title: () => `${formatMessage(messages.title)} - Modrinth`,
})
const auth = await useAuth();
const auth = await useAuth()
const success = ref(false);
const route = useNativeRoute();
const success = ref(false)
const route = useNativeRoute()
if (route.query.flow) {
try {
const emailVerified = useState("emailVerified", () => null);
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 === 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 (emailVerified.value) {
success.value = true
if (auth.value.token) {
await useAuth(auth.value.token);
}
}
} catch {
success.value = false;
}
if (auth.value.token) {
await useAuth(auth.value.token)
}
}
} catch {
success.value = false
}
}
</script>

View File

@@ -1,190 +1,192 @@
<template>
<div class="welcome-box has-bot">
<img :src="WavingRinthbot" alt="Waving Modrinth Bot" class="welcome-box__waving-bot" />
<div class="welcome-box__top-glow" />
<div class="welcome-box__body">
<h1 class="welcome-box__title">
{{ formatMessage(messages.welcomeLongTitle) }}
</h1>
<div class="welcome-box has-bot">
<img :src="WavingRinthbot" alt="Waving Modrinth Bot" class="welcome-box__waving-bot" />
<div class="welcome-box__top-glow" />
<div class="welcome-box__body">
<h1 class="welcome-box__title">
{{ formatMessage(messages.welcomeLongTitle) }}
</h1>
<p class="welcome-box__subtitle">
<IntlFormatted :message-id="messages.welcomeDescription">
<template #bold="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
</p>
<p class="welcome-box__subtitle">
<IntlFormatted :message-id="messages.welcomeDescription">
<template #bold="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
</p>
<Checkbox
v-model="subscribe"
class="subscribe-btn"
:label="formatMessage(messages.subscribeCheckbox)"
:description="formatMessage(messages.subscribeCheckbox)"
/>
<Checkbox
v-model="subscribe"
class="subscribe-btn"
:label="formatMessage(messages.subscribeCheckbox)"
:description="formatMessage(messages.subscribeCheckbox)"
/>
<button class="btn btn-primary centered-btn" @click="continueSignUp">
{{ formatMessage(commonMessages.continueButton) }}
<RightArrowIcon />
</button>
<button class="btn btn-primary centered-btn" @click="continueSignUp">
{{ formatMessage(commonMessages.continueButton) }}
<RightArrowIcon />
</button>
<p class="tos-text">
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
</div>
</div>
<p class="tos-text">
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
</div>
</div>
</template>
<script setup>
import { Checkbox, commonMessages } from "@modrinth/ui";
import { RightArrowIcon, WavingRinthbot } from "@modrinth/assets";
import { RightArrowIcon, WavingRinthbot } from '@modrinth/assets'
import { Checkbox, commonMessages } from '@modrinth/ui'
const route = useRoute();
const route = useRoute()
const { formatMessage } = useVIntl();
const { formatMessage } = useVIntl()
const messages = defineMessages({
subscribeCheckbox: {
id: "auth.welcome.checkbox.subscribe",
defaultMessage: "Subscribe to updates about Modrinth",
},
tosLabel: {
id: "auth.welcome.label.tos",
defaultMessage:
"By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
welcomeDescription: {
id: "auth.welcome.description",
defaultMessage:
"Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods.",
},
welcomeLongTitle: {
id: "auth.welcome.long-title",
defaultMessage: "Welcome to Modrinth!",
},
welcomeTitle: {
id: "auth.welcome.title",
defaultMessage: "Welcome",
},
});
subscribeCheckbox: {
id: 'auth.welcome.checkbox.subscribe',
defaultMessage: 'Subscribe to updates about Modrinth',
},
tosLabel: {
id: 'auth.welcome.label.tos',
defaultMessage:
"By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
welcomeDescription: {
id: 'auth.welcome.description',
defaultMessage:
'Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods.',
},
welcomeLongTitle: {
id: 'auth.welcome.long-title',
defaultMessage: 'Welcome to Modrinth!',
},
welcomeTitle: {
id: 'auth.welcome.title',
defaultMessage: 'Welcome',
},
})
useHead({
title: () => `${formatMessage(messages.welcomeTitle)} - Modrinth`,
});
title: () => `${formatMessage(messages.welcomeTitle)} - Modrinth`,
})
const subscribe = ref(true);
const subscribe = ref(true)
onMounted(async () => {
await useAuth(route.query.authToken);
await useUser();
});
await useAuth(route.query.authToken)
await useUser()
})
async function continueSignUp() {
if (subscribe.value) {
try {
await useBaseFetch("auth/email/subscribe", {
method: "POST",
});
} catch {}
}
if (subscribe.value) {
try {
await useBaseFetch('auth/email/subscribe', {
method: 'POST',
})
} catch {
// Ignored
}
}
if (route.query.redirect) {
await navigateTo(route.query.redirect);
} else {
await navigateTo("/dashboard");
}
if (route.query.redirect) {
await navigateTo(route.query.redirect)
} else {
await navigateTo('/dashboard')
}
}
</script>
<style lang="scss" scoped>
.welcome-box {
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-lg);
padding: 1.75rem 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
box-shadow: var(--shadow-card);
position: relative;
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-lg);
padding: 1.75rem 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
box-shadow: var(--shadow-card);
position: relative;
&.has-bot {
margin-block: 120px;
}
&.has-bot {
margin-block: 120px;
}
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: var(--weight-bold);
&:hover,
&:focus {
filter: brightness(1.125);
text-decoration: underline;
}
}
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: var(--weight-bold);
&:hover,
&:focus {
filter: brightness(1.125);
text-decoration: underline;
}
}
&__waving-bot {
--bot-height: 112px;
position: absolute;
top: calc(-1 * var(--bot-height));
right: 5rem;
height: var(--bot-height);
width: auto;
&__waving-bot {
--bot-height: 112px;
position: absolute;
top: calc(-1 * var(--bot-height));
right: 5rem;
height: var(--bot-height);
width: auto;
@media (max-width: 768px) {
--bot-height: 70px;
right: 2rem;
}
}
@media (max-width: 768px) {
--bot-height: 70px;
right: 2rem;
}
}
&__top-glow {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
opacity: 0.4;
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
}
&__top-glow {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
opacity: 0.4;
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
}
&__body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
&__body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
&__title {
font-size: var(--text-32);
font-weight: var(--weight-extrabold);
margin: 0;
}
&__title {
font-size: var(--text-32);
font-weight: var(--weight-extrabold);
margin: 0;
}
&__subtitle {
font-size: var(--text-18);
}
&__subtitle {
font-size: var(--text-18);
}
.tos-text {
font-size: var(--text-14);
line-height: 1.5;
}
.tos-text {
font-size: var(--text-14);
line-height: 1.5;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +1,67 @@
<template>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Dashboard</h1>
<NavStack>
<NavStackItem link="/dashboard" label="Overview">
<DashboardIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/notifications" label="Notifications">
<NotificationsIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/reports" label="Active reports">
<ReportIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/analytics" label="Analytics">
<ChartIcon aria-hidden="true" />
</NavStackItem>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Dashboard</h1>
<NavStack>
<NavStackItem link="/dashboard" label="Overview">
<DashboardIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/notifications" label="Notifications">
<NotificationsIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/reports" label="Active reports">
<ReportIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/analytics" label="Analytics">
<ChartIcon aria-hidden="true" />
</NavStackItem>
<h3>Manage</h3>
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
<ListIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem v-if="true" link="/dashboard/organizations" label="Organizations">
<OrganizationIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
link="/dashboard/collections"
:label="formatMessage(commonMessages.collectionsLabel)"
>
<LibraryIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/revenue" label="Revenue">
<CurrencyIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage :route="route" />
</div>
</div>
<h3>Manage</h3>
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
<ListIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem v-if="true" link="/dashboard/organizations" label="Organizations">
<OrganizationIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
link="/dashboard/collections"
:label="formatMessage(commonMessages.collectionsLabel)"
>
<LibraryIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/revenue" label="Revenue">
<CurrencyIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage :route="route" />
</div>
</div>
</template>
<script setup>
import {
DashboardIcon,
CurrencyIcon,
ListIcon,
ReportIcon,
BellIcon as NotificationsIcon,
OrganizationIcon,
LibraryIcon,
ChartIcon,
} from "@modrinth/assets";
import { commonMessages } from "@modrinth/ui";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
BellIcon as NotificationsIcon,
ChartIcon,
CurrencyIcon,
DashboardIcon,
LibraryIcon,
ListIcon,
OrganizationIcon,
ReportIcon,
} from '@modrinth/assets'
import { commonMessages } from '@modrinth/ui'
const { formatMessage } = useVIntl();
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const { formatMessage } = useVIntl()
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
const route = useNativeRoute();
const route = useNativeRoute()
</script>

View File

@@ -1,24 +1,24 @@
<template>
<div>
<ChartDisplay :projects="projects ?? undefined" :personal="true" />
</div>
<div>
<ChartDisplay :projects="projects ?? undefined" :personal="true" />
</div>
</template>
<script setup>
import ChartDisplay from "~/components/ui/charts/ChartDisplay.vue";
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
useHead({
title: "Analytics - Modrinth",
});
title: 'Analytics - Modrinth',
})
const auth = await useAuth();
const id = auth.value?.user?.id;
const auth = await useAuth()
const id = auth.value?.user?.id
const { data: projects } = await useAsyncData(`user/${id}/projects`, () =>
useBaseFetch(`user/${id}/projects`),
);
useBaseFetch(`user/${id}/projects`),
)
</script>

View File

@@ -1,251 +1,252 @@
<template>
<div class="universal-card">
<CollectionCreateModal ref="modal_creation" />
<h2 class="text-2xl">{{ formatMessage(commonMessages.collectionsLabel) }}</h2>
<div class="search-row">
<div class="iconified-input">
<label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label>
<SearchIcon aria-hidden="true" />
<input id="search-input" v-model="filterQuery" type="text" />
<Button
v-if="filterQuery"
class="r-btn"
aria-label="Clear search"
@click="() => (filterQuery = '')"
>
<XIcon aria-hidden="true" />
</Button>
</div>
<Button color="primary" @click="(event) => $refs.modal_creation.show(event)">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNewButton) }}
</Button>
</div>
<div class="collections-grid">
<nuxt-link
v-if="'followed projects'.includes(filterQuery.toLowerCase())"
:to="`/collection/following`"
class="universal-card recessed collection"
>
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
<div class="details">
<span class="title">{{ formatMessage(commonMessages.followedProjectsLabel) }}</span>
<span class="description">
{{ formatMessage(messages.followingCollectionDescription) }}
</span>
<div class="stat-bar">
<div class="stats">
<BoxIcon aria-hidden="true" />
{{
formatMessage(messages.projectsCountLabel, {
count: formatCompactNumber(user ? user.follows.length : 0),
})
}}
</div>
<div class="stats">
<LockIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</div>
</div>
</div>
</nuxt-link>
<nuxt-link
v-for="collection in orderedCollections.sort(
(a, b) => new Date(b.created) - new Date(a.created),
)"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="universal-card recessed collection"
>
<Avatar :src="collection.icon_url" class="icon" />
<div class="details">
<span class="title">{{ collection.name }}</span>
<span class="description">
{{ collection.description }}
</span>
<div class="stat-bar">
<div class="stats">
<BoxIcon aria-hidden="true" />
{{
formatMessage(messages.projectsCountLabel, {
count: formatCompactNumber(collection.projects?.length || 0),
})
}}
</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<GlobeIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
</template>
</div>
</div>
</div>
</nuxt-link>
</div>
</div>
<div class="universal-card">
<CollectionCreateModal ref="modal_creation" />
<h2 class="text-2xl">{{ formatMessage(commonMessages.collectionsLabel) }}</h2>
<div class="search-row">
<div class="iconified-input">
<label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label>
<SearchIcon aria-hidden="true" />
<input id="search-input" v-model="filterQuery" type="text" />
<Button
v-if="filterQuery"
class="r-btn"
aria-label="Clear search"
@click="() => (filterQuery = '')"
>
<XIcon aria-hidden="true" />
</Button>
</div>
<Button color="primary" @click="(event) => $refs.modal_creation.show(event)">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNewButton) }}
</Button>
</div>
<div class="collections-grid">
<nuxt-link
v-if="'followed projects'.includes(filterQuery.toLowerCase())"
:to="`/collection/following`"
class="universal-card recessed collection"
>
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
<div class="details">
<span class="title">{{ formatMessage(commonMessages.followedProjectsLabel) }}</span>
<span class="description">
{{ formatMessage(messages.followingCollectionDescription) }}
</span>
<div class="stat-bar">
<div class="stats">
<BoxIcon aria-hidden="true" />
{{
formatMessage(messages.projectsCountLabel, {
count: formatCompactNumber(user ? user.follows.length : 0),
})
}}
</div>
<div class="stats">
<LockIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</div>
</div>
</div>
</nuxt-link>
<nuxt-link
v-for="collection in orderedCollections.sort(
(a, b) => new Date(b.created) - new Date(a.created),
)"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="universal-card recessed collection"
>
<Avatar :src="collection.icon_url" class="icon" />
<div class="details">
<span class="title">{{ collection.name }}</span>
<span class="description">
{{ collection.description }}
</span>
<div class="stat-bar">
<div class="stats">
<BoxIcon aria-hidden="true" />
{{
formatMessage(messages.projectsCountLabel, {
count: formatCompactNumber(collection.projects?.length || 0),
})
}}
</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<GlobeIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon aria-hidden="true" />
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
</template>
</div>
</div>
</div>
</nuxt-link>
</div>
</div>
</template>
<script setup>
import {
BoxIcon,
SearchIcon,
XIcon,
PlusIcon,
LinkIcon,
LockIcon,
GlobeIcon,
} from "@modrinth/assets";
import { Avatar, Button, commonMessages } from "@modrinth/ui";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
BoxIcon,
GlobeIcon,
LinkIcon,
LockIcon,
PlusIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, Button, commonMessages } from '@modrinth/ui'
const { formatMessage } = useVIntl();
const formatCompactNumber = useCompactNumber();
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
const { formatMessage } = useVIntl()
const formatCompactNumber = useCompactNumber()
const messages = defineMessages({
createNewButton: {
id: "dashboard.collections.button.create-new",
defaultMessage: "Create new",
},
collectionsLongTitle: {
id: "dashboard.collections.long-title",
defaultMessage: "Your collections",
},
followingCollectionDescription: {
id: "collection.description.following",
defaultMessage: "Auto-generated collection of all the projects you're following.",
},
projectsCountLabel: {
id: "dashboard.collections.label.projects-count",
defaultMessage: "{count, plural, one {{count} project} other {{count} projects}}",
},
searchInputLabel: {
id: "dashboard.collections.label.search-input",
defaultMessage: "Search your collections",
},
});
createNewButton: {
id: 'dashboard.collections.button.create-new',
defaultMessage: 'Create new',
},
collectionsLongTitle: {
id: 'dashboard.collections.long-title',
defaultMessage: 'Your collections',
},
followingCollectionDescription: {
id: 'collection.description.following',
defaultMessage: "Auto-generated collection of all the projects you're following.",
},
projectsCountLabel: {
id: 'dashboard.collections.label.projects-count',
defaultMessage: '{count, plural, one {{count} project} other {{count} projects}}',
},
searchInputLabel: {
id: 'dashboard.collections.label.search-input',
defaultMessage: 'Search your collections',
},
})
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
useHead({
title: () => `${formatMessage(messages.collectionsLongTitle)} - Modrinth`,
});
title: () => `${formatMessage(messages.collectionsLongTitle)} - Modrinth`,
})
const auth = await useAuth();
const user = await useUser();
const auth = await useAuth()
const user = await useUser()
if (import.meta.client) {
await initUserFollows();
await initUserFollows()
}
const filterQuery = ref("");
const filterQuery = ref('')
const { data: collections } = await useAsyncData(`user/${auth.value.user.id}/collections`, () =>
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 }),
);
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 }),
)
const orderedCollections = computed(() => {
if (!collections.value) return [];
return collections.value
.sort((a, b) => {
const aUpdated = new Date(a.updated);
const bUpdated = new Date(b.updated);
return bUpdated - aUpdated;
})
.filter((collection) => {
if (!filterQuery.value) return true;
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase());
});
});
if (!collections.value) return []
return [...collections.value] // copy to avoid in-place mutation (no side effects)
.sort((a, b) => {
const aUpdated = new Date(a.updated)
const bUpdated = new Date(b.updated)
return bUpdated - aUpdated
})
.filter((collection) => {
if (!filterQuery.value) return true
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase())
})
})
</script>
<style lang="scss">
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
display: grid;
grid-template-columns: repeat(2, 1fr);
@media screen and (max-width: 800px) {
grid-template-columns: repeat(1, 1fr);
}
@media screen and (max-width: 800px) {
grid-template-columns: repeat(1, 1fr);
}
gap: var(--gap-md);
gap: var(--gap-md);
.collection {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--gap-md);
margin-bottom: 0;
.collection {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--gap-md);
margin-bottom: 0;
.icon {
width: 100% !important;
height: 6rem !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.icon {
width: 100% !important;
height: 6rem !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-md);
}
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-md);
}
.description {
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.description {
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
color: var(--color-secondary);
}
}
}
}
svg {
color: var(--color-secondary);
}
}
}
}
}
.search-row {
margin-bottom: var(--gap-lg);
display: flex;
align-items: center;
gap: var(--gap-lg) var(--gap-sm);
flex-wrap: wrap;
justify-content: center;
margin-bottom: var(--gap-lg);
display: flex;
align-items: center;
gap: var(--gap-lg) var(--gap-sm);
flex-wrap: wrap;
justify-content: center;
.iconified-input {
flex-grow: 1;
.iconified-input {
flex-grow: 1;
input {
height: 2rem;
}
}
input {
height: 2rem;
}
}
}
</style>

View File

@@ -1,212 +1,210 @@
<template>
<div class="dashboard-overview">
<section class="universal-card dashboard-header">
<Avatar :src="auth.user.avatar_url" size="md" circle :alt="auth.user.username" />
<div class="username">
<h1>
{{ auth.user.username }}
</h1>
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
Visit your profile
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
</NuxtLink>
</div>
</section>
<div class="dashboard-notifications">
<section class="universal-card">
<div class="header__row">
<h2 class="header__title text-2xl">Notifications</h2>
<nuxt-link
v-if="notifications.length > 0"
class="goto-link"
to="/dashboard/notifications"
>
See all
<ChevronRightIcon />
</nuxt-link>
</div>
<template v-if="notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
compact
@update:notifications="() => refresh()"
/>
<nuxt-link
v-if="extraNotifs > 0"
class="goto-link view-more-notifs mt-4"
to="/dashboard/notifications"
>
View {{ extraNotifs }} more notification{{ extraNotifs === 1 ? "" : "s" }}
<ChevronRightIcon />
</nuxt-link>
</template>
<div v-else class="universal-body">
<p>You have no unread notifications.</p>
<nuxt-link class="iconified-button !mt-4" to="/dashboard/notifications/history">
<HistoryIcon />
View notification history
</nuxt-link>
</div>
</section>
</div>
<div class="dashboard-overview">
<section class="universal-card dashboard-header">
<Avatar :src="auth.user.avatar_url" size="md" circle :alt="auth.user.username" />
<div class="username">
<h1>
{{ auth.user.username }}
</h1>
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
Visit your profile
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
</NuxtLink>
</div>
</section>
<div class="dashboard-notifications">
<section class="universal-card">
<div class="header__row">
<h2 class="header__title text-2xl">Notifications</h2>
<nuxt-link
v-if="notifications.length > 0"
class="goto-link"
to="/dashboard/notifications"
>
See all
<ChevronRightIcon />
</nuxt-link>
</div>
<template v-if="notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
compact
@update:notifications="() => refresh()"
/>
<nuxt-link
v-if="extraNotifs > 0"
class="goto-link view-more-notifs mt-4"
to="/dashboard/notifications"
>
View {{ extraNotifs }} more notification{{ extraNotifs === 1 ? '' : 's' }}
<ChevronRightIcon />
</nuxt-link>
</template>
<div v-else class="universal-body">
<p>You have no unread notifications.</p>
<nuxt-link class="iconified-button !mt-4" to="/dashboard/notifications/history">
<HistoryIcon />
View notification history
</nuxt-link>
</div>
</section>
</div>
<div class="dashboard-analytics">
<section class="universal-card">
<h2>Analytics</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Total downloads</div>
<div class="value">
{{ $formatNumber(projects.reduce((agg, x) => agg + x.downloads, 0)) }}
</div>
<span
>from
{{ downloadsProjectCount }}
project{{ downloadsProjectCount === 1 ? "" : "s" }}</span
>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown-->
<!-- <ChevronRightIcon-->
<!-- class="featured-header-chevron"-->
<!-- aria-hidden="true"-->
<!-- /></NuxtLink>-->
</div>
<div class="grid-display__item">
<div class="label">Total followers</div>
<div class="value">
{{ $formatNumber(projects.reduce((agg, x) => agg + x.followers, 0)) }}
</div>
<span>
<span
>from {{ followersProjectCount }} project{{
followersProjectCount === 1 ? "" : "s"
}}</span
></span
>
</div>
</div>
</section>
</div>
</div>
<div class="dashboard-analytics">
<section class="universal-card">
<h2>Analytics</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Total downloads</div>
<div class="value">
{{ $formatNumber(projects.reduce((agg, x) => agg + x.downloads, 0)) }}
</div>
<span
>from
{{ downloadsProjectCount }}
project{{ downloadsProjectCount === 1 ? '' : 's' }}</span
>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown-->
<!-- <ChevronRightIcon-->
<!-- class="featured-header-chevron"-->
<!-- aria-hidden="true"-->
<!-- /></NuxtLink>-->
</div>
<div class="grid-display__item">
<div class="label">Total followers</div>
<div class="value">
{{ $formatNumber(projects.reduce((agg, x) => agg + x.followers, 0)) }}
</div>
<span>
<span
>from {{ followersProjectCount }} project{{
followersProjectCount === 1 ? '' : 's'
}}</span
></span
>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ChevronRightIcon, HistoryIcon } from "@modrinth/assets";
import { Avatar } from "@modrinth/ui";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import {
fetchExtraNotificationData,
groupNotifications,
} from "~/helpers/platform-notifications.ts";
import { ChevronRightIcon, HistoryIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import NotificationItem from '~/components/ui/NotificationItem.vue'
import { fetchExtraNotificationData, groupNotifications } from '~/helpers/platform-notifications.ts'
useHead({
title: "Dashboard - Modrinth",
});
title: 'Dashboard - Modrinth',
})
const auth = await useAuth();
const auth = await useAuth()
const [{ data: projects }] = await Promise.all([
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
useBaseFetch(`user/${auth.value.user.id}/projects`),
),
]);
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
useBaseFetch(`user/${auth.value.user.id}/projects`),
),
])
const downloadsProjectCount = computed(
() => projects.value.filter((project) => project.downloads > 0).length,
);
() => projects.value.filter((project) => project.downloads > 0).length,
)
const followersProjectCount = computed(
() => projects.value.filter((project) => project.followers > 0).length,
);
() => projects.value.filter((project) => project.followers > 0).length,
)
const { data, refresh } = await useAsyncData(async () => {
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
const filteredNotifications = notifications.filter((notif) => !notif.read);
const slice = filteredNotifications.slice(0, 30); // send first 30 notifs to be grouped before trimming to 3
const filteredNotifications = notifications.filter((notif) => !notif.read)
const slice = filteredNotifications.slice(0, 30) // send first 30 notifs to be grouped before trimming to 3
return fetchExtraNotificationData(slice).then((notifications) => {
notifications = groupNotifications(notifications).slice(0, 3);
return { notifications, extraNotifs: filteredNotifications.length - slice.length };
});
});
return fetchExtraNotificationData(slice).then((notifications) => {
notifications = groupNotifications(notifications).slice(0, 3)
return { notifications, extraNotifs: filteredNotifications.length - slice.length }
})
})
const notifications = computed(() => {
if (data.value === null) {
return [];
}
return data.value.notifications;
});
if (data.value === null) {
return []
}
return data.value.notifications
})
const extraNotifs = computed(() => (data.value ? data.value.extraNotifs : 0));
const extraNotifs = computed(() => (data.value ? data.value.extraNotifs : 0))
</script>
<style lang="scss">
.dashboard-overview {
display: grid;
grid-template:
"header header"
"notifications analytics" / 1fr auto;
gap: var(--spacing-card-md);
display: grid;
grid-template:
'header header'
'notifications analytics' / 1fr auto;
gap: var(--spacing-card-md);
> .universal-card {
margin: 0;
}
> .universal-card {
margin: 0;
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
}
}
.dashboard-notifications {
grid-area: notifications;
//display: flex;
//flex-direction: column;
//gap: var(--spacing-card-md);
grid-area: notifications;
//display: flex;
//flex-direction: column;
//gap: var(--spacing-card-md);
a.view-more-notifs {
display: flex;
width: fit-content;
margin-left: auto;
}
a.view-more-notifs {
display: flex;
width: fit-content;
margin-left: auto;
}
}
.dashboard-analytics {
grid-area: analytics;
grid-area: analytics;
}
.dashboard-header {
display: flex;
gap: var(--spacing-card-bg);
grid-area: header;
display: flex;
gap: var(--spacing-card-bg);
grid-area: header;
.username {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
justify-content: center;
word-break: break-word;
.username {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
justify-content: center;
word-break: break-word;
h1 {
margin: 0;
}
}
h1 {
margin: 0;
}
}
@media screen and (max-width: 650px) {
.avatar {
width: 4rem;
height: 4rem;
}
@media screen and (max-width: 650px) {
.avatar {
width: 4rem;
height: 4rem;
}
.username {
h1 {
font-size: var(--font-size-xl);
}
}
}
.username {
h1 {
font-size: var(--font-size-xl);
}
}
}
}
</style>

View File

@@ -1,156 +1,157 @@
<template>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="history"
current-title="History"
:link-stack="[{ href: `/dashboard/notifications`, label: 'Notifications' }]"
/>
<div class="header__row">
<div class="header__title">
<h2 v-if="history" class="text-2xl">Notification history</h2>
<h2 v-else class="text-2xl">Notifications</h2>
</div>
<template v-if="!history">
<Button v-if="data.hasRead" @click="updateRoute()">
<HistoryIcon />
View history
</Button>
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
<CheckCheckIcon />
Mark all as read
</Button>
</template>
</div>
<Chips
v-if="notifTypes.length > 1"
v-model="selectedType"
:items="notifTypes"
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x).replace('_', ' ') + 's')"
:capitalize="false"
/>
<p v-if="pending">Loading notifications...</p>
<template v-else-if="error">
<p>Error loading notifications:</p>
<pre>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="history"
current-title="History"
:link-stack="[{ href: `/dashboard/notifications`, label: 'Notifications' }]"
/>
<div class="header__row">
<div class="header__title">
<h2 v-if="history" class="text-2xl">Notification history</h2>
<h2 v-else class="text-2xl">Notifications</h2>
</div>
<template v-if="!history">
<Button v-if="data.hasRead" @click="updateRoute()">
<HistoryIcon />
View history
</Button>
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
<CheckCheckIcon />
Mark all as read
</Button>
</template>
</div>
<Chips
v-if="notifTypes.length > 1"
v-model="selectedType"
:items="notifTypes"
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x).replace('_', ' ') + 's')"
:capitalize="false"
/>
<p v-if="pending">Loading notifications...</p>
<template v-else-if="error">
<p>Error loading notifications:</p>
<pre>
{{ error }}
</pre>
</template>
<template v-else-if="notifications && notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
@update:notifications="() => refresh()"
/>
</template>
<p v-else>You don't have any unread notifications.</p>
<div class="flex justify-end">
<Pagination :page="page" :count="pages" @switch-page="changePage" />
</div>
</section>
</div>
</template>
<template v-else-if="notifications && notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
@update:notifications="() => refresh()"
/>
</template>
<p v-else>You don't have any unread notifications.</p>
<div class="flex justify-end">
<Pagination :page="page" :count="pages" @switch-page="changePage" />
</div>
</section>
</div>
</template>
<script setup>
import { CheckCheckIcon, HistoryIcon } from "@modrinth/assets";
import { Button, Chips, Pagination } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import { CheckCheckIcon, HistoryIcon } from '@modrinth/assets'
import { Button, Chips, Pagination } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import NotificationItem from '~/components/ui/NotificationItem.vue'
import {
fetchExtraNotificationData,
groupNotifications,
markAsRead,
} from "~/helpers/platform-notifications.ts";
fetchExtraNotificationData,
groupNotifications,
markAsRead,
} from '~/helpers/platform-notifications.ts'
useHead({
title: "Notifications - Modrinth",
});
title: 'Notifications - Modrinth',
})
const auth = await useAuth();
const route = useNativeRoute();
const router = useNativeRouter();
const auth = await useAuth()
const route = useNativeRoute()
const router = useNativeRouter()
const history = computed(() => route.name === "dashboard-notifications-history");
const selectedType = ref("all");
const page = ref(1);
const perPage = ref(50);
const history = computed(() => route.name === 'dashboard-notifications-history')
const selectedType = ref('all')
const page = ref(1)
const perPage = ref(50)
const { data, pending, error, refresh } = await useAsyncData(
async () => {
const pageNum = page.value - 1;
const showRead = history.value;
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
async () => {
const pageNum = page.value - 1
const showRead = history.value
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
const typesInFeed = [
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
];
const typesInFeed = [
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
]
const filtered = notifications.filter(
(n) =>
(selectedType.value === "all" || n.type === selectedType.value) && (showRead || !n.read),
);
const filtered = notifications.filter(
(n) =>
(selectedType.value === 'all' || n.type === selectedType.value) && (showRead || !n.read),
)
const pages = Math.max(1, Math.ceil(filtered.length / perPage.value));
const pages = Math.max(1, Math.ceil(filtered.length / perPage.value))
return fetchExtraNotificationData(
filtered.slice(pageNum * perPage.value, pageNum * perPage.value + perPage.value),
).then((notifs) => ({
notifications: notifs,
notifTypes: typesInFeed.length > 1 ? ["all", ...typesInFeed] : typesInFeed,
pages,
hasRead: notifications.some((n) => n.read),
}));
},
{ watch: [page, history, selectedType] },
);
return fetchExtraNotificationData(
filtered.slice(pageNum * perPage.value, pageNum * perPage.value + perPage.value),
).then((notifs) => ({
notifications: notifs,
notifTypes: typesInFeed.length > 1 ? ['all', ...typesInFeed] : typesInFeed,
pages,
hasRead: notifications.some((n) => n.read),
}))
},
{ watch: [page, history, selectedType] },
)
const notifications = computed(() =>
data.value ? groupNotifications(data.value.notifications, history.value) : [],
);
data.value ? groupNotifications(data.value.notifications, history.value) : [],
)
const notifTypes = computed(() => data.value?.notifTypes || []);
const pages = computed(() => data.value?.pages ?? 1);
const notifTypes = computed(() => data.value?.notifTypes || [])
const pages = computed(() => data.value?.pages ?? 1)
function updateRoute() {
router.push(history.value ? "/dashboard/notifications" : "/dashboard/notifications/history");
selectedType.value = "all";
page.value = 1;
router.push(history.value ? '/dashboard/notifications' : '/dashboard/notifications/history')
selectedType.value = 'all'
page.value = 1
}
async function readAll() {
const ids = notifications.value.flatMap((n) => [
n.id,
...(n.grouped_notifs ? n.grouped_notifs.map((g) => g.id) : []),
]);
const ids = notifications.value.flatMap((n) => [
n.id,
...(n.grouped_notifs ? n.grouped_notifs.map((g) => g.id) : []),
])
await markAsRead(ids);
await refresh();
await markAsRead(ids)
await refresh()
}
function changePage(newPage) {
page.value = newPage;
if (import.meta.client) window.scrollTo({ top: 0, behavior: "smooth" });
page.value = newPage
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
<style lang="scss" scoped>
.read-toggle-input {
display: flex;
align-items: center;
gap: var(--spacing-card-md);
display: flex;
align-items: center;
gap: var(--spacing-card-md);
.label__title {
margin: 0;
}
.label__title {
margin: 0;
}
}
.header__title {
h2 {
margin: 0 auto 0 0;
}
h2 {
margin: 0 auto 0 0;
}
}
</style>

View File

@@ -1,206 +1,209 @@
<template>
<div>
<OrganizationCreateModal ref="createOrgModal" />
<section class="universal-card">
<div class="header__row">
<h2 class="header__title text-2xl">Organizations</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="openCreateOrgModal">
<PlusIcon aria-hidden="true" />
Create organization
</button>
</div>
</div>
<template v-if="orgs?.length > 0">
<div class="orgs-grid">
<nuxt-link
v-for="org in sortedOrgs"
:key="org.id"
:to="`/organization/${org.slug}`"
class="universal-card button-base recessed org"
:class="{ 'is-disabled': onlyAcceptedMembers(org.members).length === 0 }"
>
<Avatar :src="org.icon_url" :alt="org.name" class="icon" />
<div class="details">
<div class="title">
{{ org.name }}
</div>
<div class="description">
{{ org.description }}
</div>
<span class="stat-bar">
<div class="stats">
<UsersIcon aria-hidden="true" />
<span>
{{ onlyAcceptedMembers(org.members).length }} member<template
v-if="onlyAcceptedMembers(org.members).length !== 1"
>s</template
>
</span>
</div>
</span>
</div>
</nuxt-link>
</div>
</template>
<template v-else> Make an organization! </template>
</section>
</div>
<div>
<OrganizationCreateModal ref="createOrgModal" />
<section class="universal-card">
<div class="header__row">
<h2 class="header__title text-2xl">Organizations</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="openCreateOrgModal">
<PlusIcon aria-hidden="true" />
Create organization
</button>
</div>
</div>
<template v-if="orgs?.length > 0">
<div class="orgs-grid">
<nuxt-link
v-for="org in sortedOrgs"
:key="org.id"
:to="`/organization/${org.slug}`"
class="universal-card button-base recessed org"
:class="{ 'is-disabled': onlyAcceptedMembers(org.members).length === 0 }"
>
<Avatar :src="org.icon_url" :alt="org.name" class="icon" />
<div class="details">
<div class="title">
{{ org.name }}
</div>
<div class="description">
{{ org.description }}
</div>
<span class="stat-bar">
<div class="stats">
<UsersIcon aria-hidden="true" />
<span>
{{ onlyAcceptedMembers(org.members).length }}
member<template v-if="onlyAcceptedMembers(org.members).length !== 1"
>s</template
>
</span>
</div>
</span>
</div>
</nuxt-link>
</div>
</template>
<template v-else> Make an organization! </template>
</section>
</div>
</template>
<script setup>
import { PlusIcon, UsersIcon } from "@modrinth/assets";
import { Avatar } from "@modrinth/ui";
import { useAuth } from "~/composables/auth.js";
import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue";
import { PlusIcon, UsersIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
const createOrgModal = ref(null);
import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue'
import { useAuth } from '~/composables/auth.js'
const auth = await useAuth();
const uid = computed(() => auth.value.user?.id || null);
const createOrgModal = ref(null)
const { data: orgs, error } = useAsyncData("organizations", () => {
if (!uid.value) return Promise.resolve(null);
const auth = await useAuth()
const uid = computed(() => auth.value.user?.id || null)
return useBaseFetch("user/" + uid.value + "/organizations", {
apiVersion: 3,
});
});
const { data: orgs, error } = useAsyncData('organizations', () => {
if (!uid.value) return Promise.resolve(null)
const sortedOrgs = computed(() => orgs.value.sort((a, b) => a.name.localeCompare(b.name)));
return useBaseFetch('user/' + uid.value + '/organizations', {
apiVersion: 3,
})
})
const onlyAcceptedMembers = (members) => members.filter((member) => member?.accepted);
const sortedOrgs = computed(() =>
orgs.value ? [...orgs.value].sort((a, b) => a.name.localeCompare(b.name)) : [],
)
const onlyAcceptedMembers = (members) => members.filter((member) => member?.accepted)
if (error.value) {
createError({
statusCode: 500,
message: "Failed to fetch organizations",
});
createError({
statusCode: 500,
message: 'Failed to fetch organizations',
})
}
const openCreateOrgModal = (event) => {
createOrgModal.value?.show(event);
};
createOrgModal.value?.show(event)
}
</script>
<style scoped lang="scss">
.project-meta-item {
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: var(--spacing-card-sm);
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: var(--spacing-card-sm);
.project-title {
margin-bottom: var(--spacing-card-sm);
}
.project-title {
margin-bottom: var(--spacing-card-sm);
}
}
.orgs-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
display: grid;
grid-template-columns: repeat(2, 1fr);
@media screen and (max-width: 750px) {
grid-template-columns: repeat(1, 1fr);
}
@media screen and (max-width: 750px) {
grid-template-columns: repeat(1, 1fr);
}
gap: var(--gap-md);
gap: var(--gap-md);
.org {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--gap-md);
margin-bottom: 0;
.org {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--gap-md);
margin-bottom: 0;
.icon {
width: 100% !important;
height: min(6rem, 20vw) !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.icon {
width: 100% !important;
height: min(6rem, 20vw) !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-md);
}
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-md);
}
.description {
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.description {
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
color: var(--color-secondary);
}
}
}
}
svg {
color: var(--color-secondary);
}
}
}
}
}
.grid-table {
display: grid;
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
display: grid;
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
.grid-table__row {
display: contents;
.grid-table__row {
display: contents;
> div {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: var(--spacing-card-sm);
> div {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: var(--spacing-card-sm);
// Left edge of table
&:first-child {
padding-left: var(--spacing-card-bg);
}
// Left edge of table
&:first-child {
padding-left: var(--spacing-card-bg);
}
// Right edge of table
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
// Right edge of table
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
}
}
.hover-link:hover {
text-decoration: underline;
text-decoration: underline;
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,232 +1,232 @@
<template>
<div class="experimental-styles-within">
<section class="universal-card">
<h2 class="text-2xl">Revenue</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Available now</div>
<div class="value">
{{ $formatMoney(userBalance.available) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">
Total pending
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</div>
<div class="value">
{{ $formatMoney(userBalance.pending) }}
</div>
</div>
<div class="grid-display__item">
<h3 class="label m-0">
Available soon
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</h3>
<ul class="m-0 list-none p-0">
<li
v-for="date in availableSoonDateKeys"
:key="date"
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
>
<span
v-tooltip="
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
: null
"
:class="{
'cursor-help':
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
}"
class="inline-flex items-center gap-1 font-bold"
>
{{ $formatMoney(availableSoonDates[date]) }}
<template
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
>
<InProgressIcon />
</template>
</span>
<span class="text-sm text-secondary">
{{ formatDate(dayjs(date)) }}
</span>
</li>
</ul>
</div>
</div>
<div class="input-group mt-4">
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
<nuxt-link
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon />
View transfer history
</NuxtLink>
</div>
<p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
</p>
</section>
<section class="universal-card">
<h2 class="text-2xl">Payout methods</h2>
<h3>PayPal</h3>
<template v-if="auth.user.auth_providers.includes('paypal')">
<p>
Your PayPal {{ auth.user.payout_data.paypal_country }} account is currently connected with
email
{{ auth.user.payout_data.paypal_address }}
</p>
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
<XIcon />
Disconnect account
</button>
</template>
<template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
<PayPalIcon />
Sign in with PayPal
</a>
</template>
<h3>Tremendous</h3>
<p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
.
</p>
<h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
<label class="hidden" for="venmo">Venmo address</label>
<input
id="venmo"
v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
class="mt-4"
name="search"
placeholder="@example"
type="search"
/>
<button class="btn btn-secondary" @click="updateVenmo">
<SaveIcon />
Save information
</button>
</section>
</div>
<div class="experimental-styles-within">
<section class="universal-card">
<h2 class="text-2xl">Revenue</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Available now</div>
<div class="value">
{{ $formatMoney(userBalance.available) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">
Total pending
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</div>
<div class="value">
{{ $formatMoney(userBalance.pending) }}
</div>
</div>
<div class="grid-display__item">
<h3 class="label m-0">
Available soon
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</h3>
<ul class="m-0 list-none p-0">
<li
v-for="date in availableSoonDateKeys"
:key="date"
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
>
<span
v-tooltip="
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
: null
"
:class="{
'cursor-help':
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
}"
class="inline-flex items-center gap-1 font-bold"
>
{{ $formatMoney(availableSoonDates[date]) }}
<template
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
>
<InProgressIcon />
</template>
</span>
<span class="text-sm text-secondary">
{{ formatDate(dayjs(date)) }}
</span>
</li>
</ul>
</div>
</div>
<div class="input-group mt-4">
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
<nuxt-link
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon />
View transfer history
</NuxtLink>
</div>
<p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
</p>
</section>
<section class="universal-card">
<h2 class="text-2xl">Payout methods</h2>
<h3>PayPal</h3>
<template v-if="auth.user.auth_providers.includes('paypal')">
<p>
Your PayPal {{ auth.user.payout_data.paypal_country }} account is currently connected with
email
{{ auth.user.payout_data.paypal_address }}
</p>
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
<XIcon />
Disconnect account
</button>
</template>
<template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
<PayPalIcon />
Sign in with PayPal
</a>
</template>
<h3>Tremendous</h3>
<p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
.
</p>
<h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
<label class="hidden" for="venmo">Venmo address</label>
<input
id="venmo"
v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
class="mt-4"
name="search"
placeholder="@example"
type="search"
/>
<button class="btn btn-secondary" @click="updateVenmo">
<SaveIcon />
Save information
</button>
</section>
</div>
</template>
<script setup>
import {
HistoryIcon,
InProgressIcon,
PayPalIcon,
SaveIcon,
TransferIcon,
UnknownIcon,
XIcon,
} from "@modrinth/assets";
import { injectNotificationManager } from "@modrinth/ui";
import { formatDate } from "@modrinth/utils";
import dayjs from "dayjs";
import { computed } from "vue";
HistoryIcon,
InProgressIcon,
PayPalIcon,
SaveIcon,
TransferIcon,
UnknownIcon,
XIcon,
} from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { formatDate } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
const { addNotification } = injectNotificationManager();
const auth = await useAuth();
const minWithdraw = ref(0.01);
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const minWithdraw = ref(0.01)
const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
);
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
)
const deadlineEnding = computed(() => {
let deadline = dayjs().subtract(2, "month").endOf("month").add(60, "days");
if (deadline.isBefore(dayjs().startOf("day"))) {
deadline = dayjs().subtract(1, "month").endOf("month").add(60, "days");
}
return deadline;
});
let deadline = dayjs().subtract(2, 'month').endOf('month').add(60, 'days')
if (deadline.isBefore(dayjs().startOf('day'))) {
deadline = dayjs().subtract(1, 'month').endOf('month').add(60, 'days')
}
return deadline
})
const availableSoonDates = computed(() => {
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
const dates = Object.keys(userBalance.value.dates)
.filter((date) => {
const dateObj = dayjs(date);
return (
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, "month"))
);
})
.sort((a, b) => dayjs(a).diff(dayjs(b)));
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
const dates = Object.keys(userBalance.value.dates)
.filter((date) => {
const dateObj = dayjs(date)
return (
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, 'month'))
)
})
.sort((a, b) => dayjs(a).diff(dayjs(b)))
return dates.reduce((acc, date) => {
acc[date] = userBalance.value.dates[date];
return acc;
}, {});
});
return dates.reduce((acc, date) => {
acc[date] = userBalance.value.dates[date]
return acc
}, {})
})
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value))
async function updateVenmo() {
startLoading();
try {
const data = {
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
};
startLoading()
try {
const data = {
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: "PATCH",
body: data,
apiVersion: 3,
});
await useAuth(auth.value.token);
} catch (err) {
addNotification({
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
stopLoading();
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body: data,
apiVersion: 3,
})
await useAuth(auth.value.token)
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
strong {
color: var(--color-text-dark);
font-weight: 500;
color: var(--color-text-dark);
font-weight: 500;
}
.disabled-cursor-wrapper {
cursor: not-allowed;
cursor: not-allowed;
}
.disabled-link {
pointer-events: none;
pointer-events: none;
}
.grid-display {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
</style>

View File

@@ -1,225 +1,228 @@
<template>
<div>
<section class="universal-card payout-history">
<Breadcrumbs
current-title="Transfer history"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Transfer history</h2>
<p>All of your withdrawals from your Modrinth balance will be listed here:</p>
<div class="input-group">
<DropdownSelect
v-model="selectedYear"
:options="years"
:display-name="(x) => (x === 'all' ? 'All years' : x)"
name="Year filter"
/>
<DropdownSelect
v-model="selectedMethod"
:options="methods"
:display-name="
(x) => (x === 'all' ? 'Any method' : x === 'paypal' ? 'PayPal' : capitalizeString(x))
"
name="Method filter"
/>
</div>
<p>
{{
selectedYear !== "all"
? selectedMethod !== "all"
? formatMessage(messages.transfersTotalYearMethod, {
amount: $formatMoney(totalAmount),
year: selectedYear,
method: selectedMethod,
})
: formatMessage(messages.transfersTotalYear, {
amount: $formatMoney(totalAmount),
year: selectedYear,
})
: selectedMethod !== "all"
? formatMessage(messages.transfersTotalMethod, {
amount: $formatMoney(totalAmount),
method: selectedMethod,
})
: formatMessage(messages.transfersTotal, { amount: $formatMoney(totalAmount) })
}}
</p>
<div
v-for="payout in filteredPayouts"
:key="payout.id"
class="universal-card recessed payout"
>
<div class="platform">
<PayPalIcon v-if="payout.method === 'paypal'" />
<TremendousIcon v-else-if="payout.method === 'tremendous'" />
<VenmoIcon v-else-if="payout.method === 'venmo'" />
<UnknownIcon v-else />
</div>
<div class="payout-info">
<div>
<strong>
{{ $dayjs(payout.created).format("MMMM D, YYYY [at] h:mm A") }}
</strong>
</div>
<div>
<span class="amount">{{ $formatMoney(payout.amount) }}</span>
<template v-if="payout.fee">⋅ Fee {{ $formatMoney(payout.fee) }}</template>
</div>
<div class="payout-status">
<span>
<Badge v-if="payout.status === 'success'" color="green" type="Success" />
<Badge v-else-if="payout.status === 'cancelling'" color="yellow" type="Cancelling" />
<Badge v-else-if="payout.status === 'cancelled'" color="red" type="Cancelled" />
<Badge v-else-if="payout.status === 'failed'" color="red" type="Failed" />
<Badge v-else-if="payout.status === 'in-transit'" color="yellow" type="In transit" />
<Badge v-else :type="payout.status" />
</span>
<template v-if="payout.method">
<span>⋅</span>
<span>{{ formatWallet(payout.method) }} ({{ payout.method_address }})</span>
</template>
</div>
</div>
<div class="input-group">
<button
v-if="payout.status === 'in-transit'"
class="iconified-button raised-button"
@click="cancelPayout(payout.id)"
>
<XIcon /> Cancel payment
</button>
</div>
</div>
</section>
</div>
<div>
<section class="universal-card payout-history">
<Breadcrumbs
current-title="Transfer history"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Transfer history</h2>
<p>All of your withdrawals from your Modrinth balance will be listed here:</p>
<div class="input-group">
<DropdownSelect
v-model="selectedYear"
:options="years"
:display-name="(x) => (x === 'all' ? 'All years' : x)"
name="Year filter"
/>
<DropdownSelect
v-model="selectedMethod"
:options="methods"
:display-name="
(x) => (x === 'all' ? 'Any method' : x === 'paypal' ? 'PayPal' : capitalizeString(x))
"
name="Method filter"
/>
</div>
<p>
{{
selectedYear !== 'all'
? selectedMethod !== 'all'
? formatMessage(messages.transfersTotalYearMethod, {
amount: $formatMoney(totalAmount),
year: selectedYear,
method: selectedMethod,
})
: formatMessage(messages.transfersTotalYear, {
amount: $formatMoney(totalAmount),
year: selectedYear,
})
: selectedMethod !== 'all'
? formatMessage(messages.transfersTotalMethod, {
amount: $formatMoney(totalAmount),
method: selectedMethod,
})
: formatMessage(messages.transfersTotal, {
amount: $formatMoney(totalAmount),
})
}}
</p>
<div
v-for="payout in filteredPayouts"
:key="payout.id"
class="universal-card recessed payout"
>
<div class="platform">
<PayPalIcon v-if="payout.method === 'paypal'" />
<TremendousIcon v-else-if="payout.method === 'tremendous'" />
<VenmoIcon v-else-if="payout.method === 'venmo'" />
<UnknownIcon v-else />
</div>
<div class="payout-info">
<div>
<strong>
{{ $dayjs(payout.created).format('MMMM D, YYYY [at] h:mm A') }}
</strong>
</div>
<div>
<span class="amount">{{ $formatMoney(payout.amount) }}</span>
<template v-if="payout.fee">⋅ Fee {{ $formatMoney(payout.fee) }}</template>
</div>
<div class="payout-status">
<span>
<Badge v-if="payout.status === 'success'" color="green" type="Success" />
<Badge v-else-if="payout.status === 'cancelling'" color="yellow" type="Cancelling" />
<Badge v-else-if="payout.status === 'cancelled'" color="red" type="Cancelled" />
<Badge v-else-if="payout.status === 'failed'" color="red" type="Failed" />
<Badge v-else-if="payout.status === 'in-transit'" color="yellow" type="In transit" />
<Badge v-else :type="payout.status" />
</span>
<template v-if="payout.method">
<span>⋅</span>
<span>{{ formatWallet(payout.method) }} ({{ payout.method_address }})</span>
</template>
</div>
</div>
<div class="input-group">
<button
v-if="payout.status === 'in-transit'"
class="iconified-button raised-button"
@click="cancelPayout(payout.id)"
>
<XIcon /> Cancel payment
</button>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { PayPalIcon, UnknownIcon, XIcon } from "@modrinth/assets";
import { Badge, Breadcrumbs, DropdownSelect, injectNotificationManager } from "@modrinth/ui";
import { capitalizeString, formatWallet } from "@modrinth/utils";
import dayjs from "dayjs";
import TremendousIcon from "~/assets/images/external/tremendous.svg?component";
import VenmoIcon from "~/assets/images/external/venmo-small.svg?component";
import { PayPalIcon, UnknownIcon, XIcon } from '@modrinth/assets'
import { Badge, Breadcrumbs, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { capitalizeString, formatWallet } from '@modrinth/utils'
import dayjs from 'dayjs'
const { addNotification } = injectNotificationManager();
const vintl = useVIntl();
const { formatMessage } = vintl;
import TremendousIcon from '~/assets/images/external/tremendous.svg?component'
import VenmoIcon from '~/assets/images/external/venmo-small.svg?component'
const { addNotification } = injectNotificationManager()
const vintl = useVIntl()
const { formatMessage } = vintl
useHead({
title: "Transfer history - Modrinth",
});
title: 'Transfer history - Modrinth',
})
const auth = await useAuth();
const auth = await useAuth()
const { data: payouts, refresh } = await useAsyncData(`payout`, () =>
useBaseFetch(`payout`, {
apiVersion: 3,
}),
);
useBaseFetch(`payout`, {
apiVersion: 3,
}),
)
const sortedPayouts = computed(() =>
payouts.value.sort((a, b) => dayjs(b.created) - dayjs(a.created)),
);
(payouts.value ? [...payouts.value] : []).sort((a, b) => dayjs(b.created) - dayjs(a.created)),
)
const years = computed(() => {
const values = sortedPayouts.value.map((x) => dayjs(x.created).year());
return ["all", ...new Set(values)];
});
const values = sortedPayouts.value.map((x) => dayjs(x.created).year())
return ['all', ...new Set(values)]
})
const selectedYear = ref("all");
const selectedYear = ref('all')
const methods = computed(() => {
const values = sortedPayouts.value.filter((x) => x.method).map((x) => x.method);
return ["all", ...new Set(values)];
});
const values = sortedPayouts.value.filter((x) => x.method).map((x) => x.method)
return ['all', ...new Set(values)]
})
const selectedMethod = ref("all");
const selectedMethod = ref('all')
const filteredPayouts = computed(() =>
sortedPayouts.value
.filter((x) => selectedYear.value === "all" || dayjs(x.created).year() === selectedYear.value)
.filter((x) => selectedMethod.value === "all" || x.method === selectedMethod.value),
);
sortedPayouts.value
.filter((x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value)
.filter((x) => selectedMethod.value === 'all' || x.method === selectedMethod.value),
)
const totalAmount = computed(() =>
filteredPayouts.value.reduce((sum, payout) => sum + payout.amount, 0),
);
filteredPayouts.value.reduce((sum, payout) => sum + payout.amount, 0),
)
async function cancelPayout(id) {
startLoading();
try {
await useBaseFetch(`payout/${id}`, {
method: "DELETE",
apiVersion: 3,
});
await refresh();
await useAuth(auth.value.token);
} catch (err) {
addNotification({
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
stopLoading();
startLoading()
try {
await useBaseFetch(`payout/${id}`, {
method: 'DELETE',
apiVersion: 3,
})
await refresh()
await useAuth(auth.value.token)
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
const messages = defineMessages({
transfersTotal: {
id: "revenue.transfers.total",
defaultMessage: "You have withdrawn {amount} in total.",
},
transfersTotalYear: {
id: "revenue.transfers.total.year",
defaultMessage: "You have withdrawn {amount} in {year}.",
},
transfersTotalMethod: {
id: "revenue.transfers.total.method",
defaultMessage: "You have withdrawn {amount} through {method}.",
},
transfersTotalYearMethod: {
id: "revenue.transfers.total.year_method",
defaultMessage: "You have withdrawn {amount} in {year} through {method}.",
},
});
transfersTotal: {
id: 'revenue.transfers.total',
defaultMessage: 'You have withdrawn {amount} in total.',
},
transfersTotalYear: {
id: 'revenue.transfers.total.year',
defaultMessage: 'You have withdrawn {amount} in {year}.',
},
transfersTotalMethod: {
id: 'revenue.transfers.total.method',
defaultMessage: 'You have withdrawn {amount} through {method}.',
},
transfersTotalYearMethod: {
id: 'revenue.transfers.total.year_method',
defaultMessage: 'You have withdrawn {amount} in {year} through {method}.',
},
})
</script>
<style lang="scss" scoped>
.payout {
display: flex;
flex-direction: column;
gap: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
.platform {
display: flex;
padding: 0.75rem;
background-color: var(--color-raised-bg);
width: fit-content;
height: fit-content;
border-radius: 20rem;
.platform {
display: flex;
padding: 0.75rem;
background-color: var(--color-raised-bg);
width: fit-content;
height: fit-content;
border-radius: 20rem;
svg {
width: 2rem;
height: 2rem;
}
}
svg {
width: 2rem;
height: 2rem;
}
}
.payout-status {
display: flex;
gap: 0.5ch;
}
.payout-status {
display: flex;
gap: 0.5ch;
}
.amount {
color: var(--color-heading);
font-weight: 500;
}
.amount {
color: var(--color-heading);
font-weight: 500;
}
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
.input-group {
margin-left: auto;
}
}
}
</style>

View File

@@ -1,536 +1,538 @@
<template>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Withdraw</h2>
<h2>Withdraw</h2>
<h3>Region</h3>
<Multiselect
id="country-multiselect"
v-model="country"
class="country-multiselect"
placeholder="Select country..."
track-by="id"
label="name"
:options="countries"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<h3>Region</h3>
<Multiselect
id="country-multiselect"
v-model="country"
class="country-multiselect"
placeholder="Select country..."
track-by="id"
label="name"
:options="countries"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<h3>Withdraw method</h3>
<h3>Withdraw method</h3>
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="search"
name="search"
placeholder="Search options..."
autocomplete="off"
/>
</div>
<div class="withdraw-options-scroll">
<div class="withdraw-options">
<button
v-for="method in payoutMethods
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
a.type !== 'tremendous'
? -1
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
)"
:key="method.id"
class="withdraw-option button-base"
:class="{ selected: selectedMethodId === method.id }"
@click="() => (selectedMethodId = method.id)"
>
<div class="preview" :class="{ 'show-bg': !method.image_url || method.name === 'ACH' }">
<template v-if="method.image_url && method.name !== 'ACH'">
<div class="preview-badges">
<span class="badge">
{{
getRangeOfMethod(method)
.map($formatMoney)
.map((i) => i.replace(".00", ""))
.join("")
}}
</span>
</div>
<img
v-if="method.image_url && method.name !== 'ACH'"
class="preview-img"
:src="method.image_url"
:alt="method.name"
/>
</template>
<div v-else class="placeholder">
<template v-if="method.type === 'venmo'">
<VenmoIcon class="enlarge" />
</template>
<template v-else>
<PayPalIcon v-if="method.type === 'paypal'" />
<span>{{ method.name }}</span>
</template>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon v-if="selectedMethodId === method.id" class="radio" />
<RadioButtonIcon v-else class="radio" />
<span>{{ method.name }}</span>
</div>
</button>
</div>
</div>
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="search"
name="search"
placeholder="Search options..."
autocomplete="off"
/>
</div>
<div class="withdraw-options-scroll">
<div class="withdraw-options">
<button
v-for="method in payoutMethods
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
a.type !== 'tremendous'
? -1
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
)"
:key="method.id"
class="withdraw-option button-base"
:class="{ selected: selectedMethodId === method.id }"
@click="() => (selectedMethodId = method.id)"
>
<div class="preview" :class="{ 'show-bg': !method.image_url || method.name === 'ACH' }">
<template v-if="method.image_url && method.name !== 'ACH'">
<div class="preview-badges">
<span class="badge">
{{
getRangeOfMethod(method)
.map($formatMoney)
.map((i) => i.replace('.00', ''))
.join('')
}}
</span>
</div>
<img
v-if="method.image_url && method.name !== 'ACH'"
class="preview-img"
:src="method.image_url"
:alt="method.name"
/>
</template>
<div v-else class="placeholder">
<template v-if="method.type === 'venmo'">
<VenmoIcon class="enlarge" />
</template>
<template v-else>
<PayPalIcon v-if="method.type === 'paypal'" />
<span>{{ method.name }}</span>
</template>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon v-if="selectedMethodId === method.id" class="radio" />
<RadioButtonIcon v-else class="radio" />
<span>{{ method.name }}</span>
</div>
</button>
</div>
</div>
<h3>Amount</h3>
<p>
You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
How much of your
<strong>{{ $formatMoney(userBalance.available) }}</strong> balance would you like to transfer
transfer to {{ selectedMethod.name }}?
</p>
<div class="confirmation-input">
<template v-if="selectedMethod.interval.fixed">
<Chips
v-model="amount"
:items="selectedMethod.interval.fixed.values"
:format-label="(val) => '$' + val"
/>
</template>
<template v-else-if="minWithdrawAmount == maxWithdrawAmount">
<div>
<p>
This method has a fixed transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong
>.
</p>
</div>
</template>
<template v-else>
<div>
<p>
This method has a minimum transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong> and a maximum transfer amount of
<strong>{{ $formatMoney(maxWithdrawAmount) }}</strong
>.
</p>
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
<p>
You have entered <strong>{{ $formatMoney(parsedAmount) }}</strong> to transfer.
</p>
</div>
</template>
</div>
<h3>Amount</h3>
<p>
You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
How much of your
<strong>{{ $formatMoney(userBalance.available) }}</strong> balance would you like to transfer
transfer to {{ selectedMethod.name }}?
</p>
<div class="confirmation-input">
<template v-if="selectedMethod.interval.fixed">
<Chips
v-model="amount"
:items="selectedMethod.interval.fixed.values"
:format-label="(val) => '$' + val"
/>
</template>
<template v-else-if="minWithdrawAmount == maxWithdrawAmount">
<div>
<p>
This method has a fixed transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong
>.
</p>
</div>
</template>
<template v-else>
<div>
<p>
This method has a minimum transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong> and a maximum transfer amount of
<strong>{{ $formatMoney(maxWithdrawAmount) }}</strong
>.
</p>
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
<p>
You have entered <strong>{{ $formatMoney(parsedAmount) }}</strong> to transfer.
</p>
</div>
</template>
</div>
<div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
I acknowledge that an estimated
{{ formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ formatWallet(selectedMethod.type) }} processing fees.
</Checkbox>
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
<template v-if="selectedMethod.type === 'tremendous'">
I confirm that I am initiating a transfer and I will receive further instructions on how
to redeem this payment via email to: {{ withdrawAccount }}
</template>
<template v-else>
I confirm that I am initiating a transfer to the following
{{ formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
</template>
</Checkbox>
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
I agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>
</Checkbox>
</template>
<template v-else>
<span v-for="(error, index) in knownErrors" :key="index" class="invalid">
{{ error }}
</span>
</template>
</div>
<div class="button-group">
<nuxt-link to="/dashboard/revenue" class="iconified-button">
<XIcon />
Cancel
</nuxt-link>
<button
:disabled="
knownErrors.length > 0 ||
!amount ||
!agreedTransfer ||
!agreedTerms ||
(fees > 0 && !agreedFees)
"
class="iconified-button brand-button"
@click="withdraw"
>
<TransferIcon />
Withdraw
</button>
</div>
</section>
<div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
I acknowledge that an estimated
{{ formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ formatWallet(selectedMethod.type) }} processing fees.
</Checkbox>
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
<template v-if="selectedMethod.type === 'tremendous'">
I confirm that I am initiating a transfer and I will receive further instructions on how
to redeem this payment via email to:
{{ withdrawAccount }}
</template>
<template v-else>
I confirm that I am initiating a transfer to the following
{{ formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
</template>
</Checkbox>
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
I agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>
</Checkbox>
</template>
<template v-else>
<span v-for="(error, index) in knownErrors" :key="index" class="invalid">
{{ error }}
</span>
</template>
</div>
<div class="button-group">
<nuxt-link to="/dashboard/revenue" class="iconified-button">
<XIcon />
Cancel
</nuxt-link>
<button
:disabled="
knownErrors.length > 0 ||
!amount ||
!agreedTransfer ||
!agreedTerms ||
(fees > 0 && !agreedFees)
"
class="iconified-button brand-button"
@click="withdraw"
>
<TransferIcon />
Withdraw
</button>
</div>
</section>
</template>
<script setup>
import {
PayPalIcon,
RadioButtonCheckedIcon,
RadioButtonIcon,
SearchIcon,
TransferIcon,
XIcon,
} from "@modrinth/assets";
import { Breadcrumbs, Checkbox, Chips, injectNotificationManager } from "@modrinth/ui";
import { formatMoney, formatWallet } from "@modrinth/utils";
import { all } from "iso-3166-1";
import { Multiselect } from "vue-multiselect";
import VenmoIcon from "~/assets/images/external/venmo.svg?component";
PayPalIcon,
RadioButtonCheckedIcon,
RadioButtonIcon,
SearchIcon,
TransferIcon,
XIcon,
} from '@modrinth/assets'
import { Breadcrumbs, Checkbox, Chips, injectNotificationManager } from '@modrinth/ui'
import { formatMoney, formatWallet } from '@modrinth/utils'
import { all } from 'iso-3166-1'
import { Multiselect } from 'vue-multiselect'
const { addNotification } = injectNotificationManager();
const auth = await useAuth();
const data = useNuxtApp();
import VenmoIcon from '~/assets/images/external/venmo.svg?component'
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const data = useNuxtApp()
const countries = computed(() =>
all().map((x) => ({
id: x.alpha2,
name: x.alpha2 === "TW" ? "Taiwan" : x.country,
})),
);
const search = ref("");
all().map((x) => ({
id: x.alpha2,
name: x.alpha2 === 'TW' ? 'Taiwan' : x.country,
})),
)
const search = ref('')
const amount = ref("");
const amount = ref('')
const country = ref(
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? "US")),
);
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? 'US')),
)
const [{ data: userBalance }, { data: payoutMethods, refresh: refreshPayoutMethods }] =
await Promise.all([
useAsyncData(`payout/balance`, () => useBaseFetch(`payout/balance`, { apiVersion: 3 })),
useAsyncData(`payout/methods?country=${country.value.id}`, () =>
useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 }),
),
]);
await Promise.all([
useAsyncData(`payout/balance`, () => useBaseFetch(`payout/balance`, { apiVersion: 3 })),
useAsyncData(`payout/methods?country=${country.value.id}`, () =>
useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 }),
),
])
const selectedMethodId = ref(payoutMethods.value[0].id);
const selectedMethodId = ref(payoutMethods.value[0].id)
const selectedMethod = computed(() =>
payoutMethods.value.find((x) => x.id === selectedMethodId.value),
);
payoutMethods.value.find((x) => x.id === selectedMethodId.value),
)
const parsedAmount = computed(() => {
const regex = /^\$?(\d*(\.\d{2})?)$/gm;
const matches = regex.exec(amount.value);
return matches && matches[1] ? parseFloat(matches[1]) : 0.0;
});
const regex = /^\$?(\d*(\.\d{2})?)$/gm
const matches = regex.exec(amount.value)
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
})
const fees = computed(() => {
return Math.min(
Math.max(
selectedMethod.value.fee.min,
selectedMethod.value.fee.percentage * parsedAmount.value,
),
selectedMethod.value.fee.max ?? Number.MAX_VALUE,
);
});
return Math.min(
Math.max(
selectedMethod.value.fee.min,
selectedMethod.value.fee.percentage * parsedAmount.value,
),
selectedMethod.value.fee.max ?? Number.MAX_VALUE,
)
})
const getIntervalRange = (intervalType) => {
if (!intervalType) {
return [];
}
if (!intervalType) {
return []
}
const { min, max, values } = intervalType;
if (values) {
const first = values[0];
const last = values.slice(-1)[0];
return first === last ? [first] : [first, last];
}
const { min, max, values } = intervalType
if (values) {
const first = values[0]
const last = values.slice(-1)[0]
return first === last ? [first] : [first, last]
}
return min === max ? [min] : [min, max];
};
return min === max ? [min] : [min, max]
}
const getRangeOfMethod = (method) => {
return getIntervalRange(method.interval?.fixed || method.interval?.standard);
};
return getIntervalRange(method.interval?.fixed || method.interval?.standard)
}
const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
});
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0)
})
const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
});
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value)
})
const withdrawAccount = computed(() => {
if (selectedMethod.value.type === "paypal") {
return auth.value.user.payout_data.paypal_address;
} else if (selectedMethod.value.type === "venmo") {
return auth.value.user.payout_data.venmo_handle;
} else {
return auth.value.user.email;
}
});
if (selectedMethod.value.type === 'paypal') {
return auth.value.user.payout_data.paypal_address
} else if (selectedMethod.value.type === 'venmo') {
return auth.value.user.payout_data.venmo_handle
} else {
return auth.value.user.email
}
})
const knownErrors = computed(() => {
const errors = [];
if (selectedMethod.value.type === "paypal" && !auth.value.user.payout_data.paypal_address) {
errors.push("Please link your PayPal account in the dashboard to proceed.");
}
if (selectedMethod.value.type === "venmo" && !auth.value.user.payout_data.venmo_handle) {
errors.push("Please set your Venmo handle in the dashboard to proceed.");
}
if (selectedMethod.value.type === "tremendous") {
if (!auth.value.user.email) {
errors.push("Please set your email address in your account settings to proceed.");
}
if (!auth.value.user.email_verified) {
errors.push("Please verify your email address to proceed.");
}
}
const errors = []
if (selectedMethod.value.type === 'paypal' && !auth.value.user.payout_data.paypal_address) {
errors.push('Please link your PayPal account in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'venmo' && !auth.value.user.payout_data.venmo_handle) {
errors.push('Please set your Venmo handle in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'tremendous') {
if (!auth.value.user.email) {
errors.push('Please set your email address in your account settings to proceed.')
}
if (!auth.value.user.email_verified) {
errors.push('Please verify your email address to proceed.')
}
}
if (!parsedAmount.value && amount.value.length > 0) {
errors.push(`${amount.value} is not a valid amount`);
} else if (
parsedAmount.value > userBalance.value.available ||
parsedAmount.value > maxWithdrawAmount.value
) {
const maxAmount = Math.min(userBalance.value.available, maxWithdrawAmount.value);
errors.push(`The amount must be no more than ${data.$formatMoney(maxAmount)}`);
} else if (parsedAmount.value <= fees.value || parsedAmount.value < minWithdrawAmount.value) {
const minAmount = Math.max(fees.value + 0.01, minWithdrawAmount.value);
errors.push(`The amount must be at least ${data.$formatMoney(minAmount)}`);
}
if (!parsedAmount.value && amount.value.length > 0) {
errors.push(`${amount.value} is not a valid amount`)
} else if (
parsedAmount.value > userBalance.value.available ||
parsedAmount.value > maxWithdrawAmount.value
) {
const maxAmount = Math.min(userBalance.value.available, maxWithdrawAmount.value)
errors.push(`The amount must be no more than ${data.$formatMoney(maxAmount)}`)
} else if (parsedAmount.value <= fees.value || parsedAmount.value < minWithdrawAmount.value) {
const minAmount = Math.max(fees.value + 0.01, minWithdrawAmount.value)
errors.push(`The amount must be at least ${data.$formatMoney(minAmount)}`)
}
return errors;
});
return errors
})
const agreedTransfer = ref(false);
const agreedFees = ref(false);
const agreedTerms = ref(false);
const agreedTransfer = ref(false)
const agreedFees = ref(false)
const agreedTerms = ref(false)
watch(country, async () => {
await refreshPayoutMethods();
if (payoutMethods.value && payoutMethods.value[0]) {
selectedMethodId.value = payoutMethods.value[0].id;
}
});
await refreshPayoutMethods()
if (payoutMethods.value && payoutMethods.value[0]) {
selectedMethodId.value = payoutMethods.value[0].id
}
})
watch(selectedMethod, () => {
if (selectedMethod.value.interval?.fixed) {
amount.value = selectedMethod.value.interval.fixed.values[0];
}
if (maxWithdrawAmount.value === minWithdrawAmount.value) {
amount.value = maxWithdrawAmount.value;
}
agreedTransfer.value = false;
agreedFees.value = false;
agreedTerms.value = false;
});
if (selectedMethod.value.interval?.fixed) {
amount.value = selectedMethod.value.interval.fixed.values[0]
}
if (maxWithdrawAmount.value === minWithdrawAmount.value) {
amount.value = maxWithdrawAmount.value
}
agreedTransfer.value = false
agreedFees.value = false
agreedTerms.value = false
})
async function withdraw() {
startLoading();
try {
const auth = await useAuth();
startLoading()
try {
const auth = await useAuth()
await useBaseFetch(`payout`, {
method: "POST",
body: {
amount: parsedAmount.value,
method: selectedMethod.value.type,
method_id: selectedMethod.value.id,
},
apiVersion: 3,
});
await useAuth(auth.value.token);
await navigateTo("/dashboard/revenue");
addNotification({
title: "Withdrawal complete",
text:
selectedMethod.value.type === "tremendous"
? "An email has been sent to your account with further instructions on how to redeem your payout!"
: `Payment has been sent to your ${formatWallet(selectedMethod.value.type)} account!`,
type: "success",
});
} catch (err) {
addNotification({
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
stopLoading();
await useBaseFetch(`payout`, {
method: 'POST',
body: {
amount: parsedAmount.value,
method: selectedMethod.value.type,
method_id: selectedMethod.value.id,
},
apiVersion: 3,
})
await useAuth(auth.value.token)
await navigateTo('/dashboard/revenue')
addNotification({
title: 'Withdrawal complete',
text:
selectedMethod.value.type === 'tremendous'
? 'An email has been sent to your account with further instructions on how to redeem your payout!'
: `Payment has been sent to your ${formatWallet(selectedMethod.value.type)} account!`,
type: 'success',
})
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.withdraw-options-scroll {
max-height: 460px;
overflow-y: auto;
max-height: 460px;
overflow-y: auto;
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
}
}
.withdraw-options {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: var(--gap-lg);
padding-right: 0.5rem;
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: var(--gap-lg);
padding-right: 0.5rem;
@media screen and (min-width: 300px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 300px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (min-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
}
.withdraw-option {
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-text);
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-text);
&.selected {
color: var(--color-contrast);
&.selected {
color: var(--color-contrast);
.label svg {
color: var(--color-brand);
}
}
.label svg {
color: var(--color-brand);
}
}
.preview {
display: flex;
justify-content: center;
aspect-ratio: 30 / 19;
position: relative;
.preview {
display: flex;
justify-content: center;
aspect-ratio: 30 / 19;
position: relative;
.preview-badges {
// These will float over the image in the bottom right corner
position: absolute;
bottom: 0;
right: 0;
padding: var(--gap-sm) var(--gap-xs);
.preview-badges {
// These will float over the image in the bottom right corner
position: absolute;
bottom: 0;
right: 0;
padding: var(--gap-sm) var(--gap-xs);
.badge {
background-color: var(--color-button-bg);
border-radius: var(--radius-xs);
padding: var(--gap-xs) var(--gap-sm);
font-size: var(--font-size-xs);
}
}
.badge {
background-color: var(--color-button-bg);
border-radius: var(--radius-xs);
padding: var(--gap-xs) var(--gap-sm);
font-size: var(--font-size-xs);
}
}
&.show-bg {
background-color: var(--color-bg);
}
&.show-bg {
background-color: var(--color-bg);
}
img {
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
user-select: none;
width: 100%;
height: auto;
object-fit: cover;
}
img {
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
user-select: none;
width: 100%;
height: auto;
object-fit: cover;
}
.placeholder {
display: flex;
align-items: center;
gap: var(--gap-xs);
.placeholder {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
width: 2rem;
height: auto;
}
svg {
width: 2rem;
height: auto;
}
span {
font-weight: var(--font-weight-bold);
font-size: 2rem;
font-style: italic;
}
span {
font-weight: var(--font-weight-bold);
font-size: 2rem;
font-style: italic;
}
.enlarge {
width: auto;
height: 1.5rem;
}
}
}
.enlarge {
width: auto;
height: 1.5rem;
}
}
}
.label {
display: flex;
align-items: center;
padding: var(--gap-md) var(--gap-lg);
.label {
display: flex;
align-items: center;
padding: var(--gap-md) var(--gap-lg);
svg {
min-height: 1rem;
min-width: 1rem;
margin-right: 0.5rem;
}
svg {
min-height: 1rem;
min-width: 1rem;
margin-right: 0.5rem;
}
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.invalid {
color: var(--color-red);
color: var(--color-red);
}
.confirm-text {
margin: var(--spacing-card-md) 0;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
margin: var(--spacing-card-md) 0;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
}
.iconified-input {
margin-bottom: var(--spacing-card-md);
margin-bottom: var(--spacing-card-md);
}
.country-multiselect,
.iconified-input {
max-width: 16rem;
max-width: 16rem;
}
.rewards-checkbox {
a {
margin-left: 0.5ch;
}
a {
margin-left: 0.5ch;
}
}
</style>

View File

@@ -1,67 +1,67 @@
<script setup lang="ts">
import {
type FeatureFlag,
DEFAULT_FEATURE_FLAGS,
saveFeatureFlags,
} from "~/composables/featureFlags.ts";
DEFAULT_FEATURE_FLAGS,
type FeatureFlag,
saveFeatureFlags,
} from '~/composables/featureFlags.ts'
const flags = shallowReactive(useFeatureFlags().value);
const flags = shallowReactive(useFeatureFlags().value)
</script>
<template>
<div class="page">
<h1>Feature flags</h1>
<div class="flags">
<div
v-for="flag in Object.keys(flags) as FeatureFlag[]"
:key="`flag-${flag}`"
class="adjacent-input small card"
>
<label :for="`toggle-${flag}`">
<span class="label__title">
{{ flag.replaceAll("_", " ") }}
</span>
<span class="label__description">
<p>
Default:
<span
:style="`color:var(--color-${
DEFAULT_FEATURE_FLAGS[flag] === false ? 'red' : 'green'
})`"
>{{ DEFAULT_FEATURE_FLAGS[flag] }}</span
>
</p>
</span>
</label>
<input
:id="`toggle-${flag}`"
v-model="flags[flag]"
class="switch stylized-toggle"
type="checkbox"
@change="() => saveFeatureFlags()"
/>
</div>
</div>
</div>
<div class="page">
<h1>Feature flags</h1>
<div class="flags">
<div
v-for="flag in Object.keys(flags) as FeatureFlag[]"
:key="`flag-${flag}`"
class="adjacent-input small card"
>
<label :for="`toggle-${flag}`">
<span class="label__title">
{{ flag.replaceAll('_', ' ') }}
</span>
<span class="label__description">
<p>
Default:
<span
:style="`color:var(--color-${
DEFAULT_FEATURE_FLAGS[flag] === false ? 'red' : 'green'
})`"
>{{ DEFAULT_FEATURE_FLAGS[flag] }}</span
>
</p>
</span>
</label>
<input
:id="`toggle-${flag}`"
v-model="flags[flag]"
class="switch stylized-toggle"
type="checkbox"
@change="() => saveFeatureFlags()"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.page {
width: calc(100% - 2 * var(--spacing-card-md));
max-width: 800px;
margin-inline: auto;
box-sizing: border-box;
margin-block: var(--spacing-card-md);
width: calc(100% - 2 * var(--spacing-card-md));
max-width: 800px;
margin-inline: auto;
box-sizing: border-box;
margin-block: var(--spacing-card-md);
}
.flags {
}
.label__title {
text-transform: capitalize;
text-transform: capitalize;
}
.label__description p {
margin: 0;
margin: 0;
}
</style>

View File

@@ -1,77 +1,77 @@
<script setup lang="ts">
import { useRelativeTime } from "@modrinth/ui";
import { useRelativeTime } from '@modrinth/ui'
const vintl = useVIntl();
const { formatMessage } = vintl;
const vintl = useVIntl()
const { formatMessage } = vintl
const messages = defineMessages({
frogTitle: {
id: "frog.title",
defaultMessage: "Frog",
},
frogDescription: {
id: "frog",
defaultMessage: "You've been frogged! 🐸",
},
frogAltText: {
id: "frog.altText",
defaultMessage: "A photorealistic painting of a frog labyrinth",
},
frogSinceOpened: {
id: "frog.sinceOpened",
defaultMessage: "This page was opened {ago}",
},
frogFroggedPeople: {
id: "frog.froggedPeople",
defaultMessage:
"{count, plural, one {{count} more person} other {{count} more people}} were also frogged!",
},
});
frogTitle: {
id: 'frog.title',
defaultMessage: 'Frog',
},
frogDescription: {
id: 'frog',
defaultMessage: "You've been frogged! 🐸",
},
frogAltText: {
id: 'frog.altText',
defaultMessage: 'A photorealistic painting of a frog labyrinth',
},
frogSinceOpened: {
id: 'frog.sinceOpened',
defaultMessage: 'This page was opened {ago}',
},
frogFroggedPeople: {
id: 'frog.froggedPeople',
defaultMessage:
'{count, plural, one {{count} more person} other {{count} more people}} were also frogged!',
},
})
const formatCompactNumber = useCompactNumber();
const formatCompactNumber = useCompactNumber()
const formatRelativeTime = useRelativeTime();
const formatRelativeTime = useRelativeTime()
const pageOpen = useState("frogPageOpen", () => Date.now());
const peopleFrogged = useState("frogPeopleFrogged", () => Math.round(Math.random() * 100_000_000));
const peopleFroggedCount = computed(() => formatCompactNumber(peopleFrogged.value));
const pageOpen = useState('frogPageOpen', () => Date.now())
const peopleFrogged = useState('frogPeopleFrogged', () => Math.round(Math.random() * 100_000_000))
const peopleFroggedCount = computed(() => formatCompactNumber(peopleFrogged.value))
let interval: ReturnType<typeof setTimeout>;
let interval: ReturnType<typeof setTimeout>
const formattedOpenedCounter = ref(formatRelativeTime(Date.now()));
const formattedOpenedCounter = ref(formatRelativeTime(Date.now()))
onMounted(() => {
interval = setInterval(() => {
formattedOpenedCounter.value = formatRelativeTime(pageOpen.value);
}, 1000);
});
interval = setInterval(() => {
formattedOpenedCounter.value = formatRelativeTime(pageOpen.value)
}, 1000)
})
onUnmounted(() => clearInterval(interval));
onUnmounted(() => clearInterval(interval))
</script>
<template>
<div class="card">
<h1>{{ formatMessage(messages.frogTitle) }}</h1>
<p>{{ formatMessage(messages.frogDescription) }}</p>
<img src="https://cdn.modrinth.com/frog.png" :alt="formatMessage(messages.frogAltText)" />
<p>{{ formatMessage(messages.frogSinceOpened, { ago: formattedOpenedCounter }) }}</p>
<p>{{ formatMessage(messages.frogFroggedPeople, { count: peopleFroggedCount }) }}</p>
</div>
<div class="card">
<h1>{{ formatMessage(messages.frogTitle) }}</h1>
<p>{{ formatMessage(messages.frogDescription) }}</p>
<img src="https://cdn.modrinth.com/frog.png" :alt="formatMessage(messages.frogAltText)" />
<p>{{ formatMessage(messages.frogSinceOpened, { ago: formattedOpenedCounter }) }}</p>
<p>{{ formatMessage(messages.frogFroggedPeople, { count: peopleFroggedCount }) }}</p>
</div>
</template>
<style lang="scss" scoped>
.card {
width: calc(100% - 2 * var(--spacing-card-md));
max-width: 1280px;
margin-inline: auto;
text-align: center;
box-sizing: border-box;
margin-block: var(--spacing-card-md);
width: calc(100% - 2 * var(--spacing-card-md));
max-width: 1280px;
margin-inline: auto;
text-align: center;
box-sizing: border-box;
margin-block: var(--spacing-card-md);
}
img {
margin-block: 0 1.5rem;
width: 60%;
max-width: 40rem;
margin-block: 0 1.5rem;
width: 60%;
max-width: 40rem;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +1,75 @@
<template>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Legal</h1>
<NavStack>
<NavStackItem link="/legal/terms" label="Terms of Use">
<HeartHandshakeIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/rules" label="Content Rules">
<ScaleIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/copyright" label="Copyright Policy">
<CopyrightIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/security" label="Security Notice">
<ShieldIcon aria-hidden="true" />
</NavStackItem>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Legal</h1>
<NavStack>
<NavStackItem link="/legal/terms" label="Terms of Use">
<HeartHandshakeIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/rules" label="Content Rules">
<ScaleIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/copyright" label="Copyright Policy">
<CopyrightIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/security" label="Security Notice">
<ShieldIcon aria-hidden="true" />
</NavStackItem>
<h3>Privacy</h3>
<NavStackItem link="/legal/privacy" label="Privacy Policy">
<LockIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/ccpa" label="California Privacy Notice">
<InfoIcon aria-hidden="true" />
</NavStackItem>
<h3>Privacy</h3>
<NavStackItem link="/legal/privacy" label="Privacy Policy">
<LockIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/ccpa" label="California Privacy Notice">
<InfoIcon aria-hidden="true" />
</NavStackItem>
<h3>Rewards Program</h3>
<NavStackItem link="/legal/cmp" label="Rewards Program Terms">
<CurrencyIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/cmp-info" label="Rewards Program Info">
<InfoIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage class="universal-card" :route="route" />
</div>
</div>
<h3>Rewards Program</h3>
<NavStackItem link="/legal/cmp" label="Rewards Program Terms">
<CurrencyIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/cmp-info" label="Rewards Program Info">
<InfoIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage class="universal-card" :route="route" />
</div>
</div>
</template>
<script setup>
import {
InfoIcon,
HeartHandshakeIcon,
LockIcon,
ScaleIcon,
ShieldIcon,
CurrencyIcon,
CopyrightIcon,
} from "@modrinth/assets";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
CopyrightIcon,
CurrencyIcon,
HeartHandshakeIcon,
InfoIcon,
LockIcon,
ScaleIcon,
ShieldIcon,
} from '@modrinth/assets'
const route = useNativeRoute();
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const route = useNativeRoute()
</script>
<style lang="scss" scoped>
.normal-page__content :deep(a) {
color: var(--color-link);
text-decoration: underline;
color: var(--color-link);
text-decoration: underline;
&:focus-visible,
&:hover {
color: var(--color-link-hover);
}
&:focus-visible,
&:hover {
color: var(--color-link-hover);
}
&:active {
color: var(--color-link-active);
}
&:active {
color: var(--color-link-active);
}
}
</style>

View File

@@ -1,466 +1,466 @@
<template>
<div class="markdown-body">
<h1>Privacy Notice for California Residents</h1>
<p><strong>Effective Date: </strong><em>August 5th, 2023</em></p>
<p><strong>Last reviewed on: </strong><em>March 11th, 2025</em></p>
<p>
This Privacy Notice for California Residents supplements the information contained in the
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link> of Rinth, Inc. (the "Company," "we,"
"us" or "our") and applies solely to all visitors, users, and others who reside in the State
of California ("consumers" or "you"). We adopt this notice to comply with the California
Consumer Privacy Act of 2018 (CCPA), as it may be amended, modified or supplemented from time
to time, and any terms defined in the CCPA have the same meaning when used in this notice.
</p>
<div class="markdown-body">
<h1>Privacy Notice for California Residents</h1>
<p><strong>Effective Date: </strong><em>August 5th, 2023</em></p>
<p><strong>Last reviewed on: </strong><em>March 11th, 2025</em></p>
<p>
This Privacy Notice for California Residents supplements the information contained in the
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link> of Rinth, Inc. (the "Company," "we,"
"us" or "our") and applies solely to all visitors, users, and others who reside in the State
of California ("consumers" or "you"). We adopt this notice to comply with the California
Consumer Privacy Act of 2018 (CCPA), as it may be amended, modified or supplemented from time
to time, and any terms defined in the CCPA have the same meaning when used in this notice.
</p>
<h2>Information We Collect</h2>
<p>
Our Service collects information that identifies, relates to, describes, references, is
capable of being associated with, or could reasonably be linked, directly or indirectly, with
a particular consumer or device (<strong>"personal information"</strong>). In particular, our
Service has collected the following categories of personal information from its consumers
within the last twelve (12) months:
</p>
<table>
<tbody>
<tr>
<th>Category</th>
<th>Examples</th>
<th>Collected</th>
</tr>
<tr>
<td>A. Identifiers.</td>
<td>
A real name, alias, postal address, unique personal identifier, online identifier,
Internet Protocol address, email address, account name, Social Security number, driver's
license number, passport number, or other similar identifiers.
</td>
<td>YES</td>
</tr>
<tr>
<td>
B. Personal information categories listed in the California Customer Records statute
(Cal. Civ. Code § 1798.80(e)).
</td>
<td>
A name, signature, Social Security number, physical characteristics or description,
address, telephone number, passport number, driver's license or state identification
card number, insurance policy number, education, employment, employment history, bank
account number, credit card number, debit card number, or any other financial
information, medical information, or health insurance information. <br /><br />
Some personal information included in this category may overlap with other categories.
</td>
<td>YES</td>
</tr>
<tr>
<td>C. Protected classification characteristics.</td>
<td>
Age (40 years or older), race, color, ancestry, national origin, citizenship, religion
or creed, marital status, medical condition, physical or mental disability, sex
(including gender, gender identity, gender expression, pregnancy or childbirth and
related medical conditions), sexual orientation, veteran or military status, genetic
information (including familial genetic information).
</td>
<td>NO</td>
</tr>
<tr>
<td>D. Commercial information.</td>
<td>
Records of personal property, products or services purchased, obtained, or considered,
or other purchasing or consuming histories or tendencies.
</td>
<td>YES</td>
</tr>
<tr>
<td>E. Biometric information.</td>
<td>
Genetic, physiological, behavioral, and biological characteristics, or activity patterns
used to extract a template or other identifier or identifying information, such as,
fingerprints, faceprints, and voiceprints, iris or retina scans, keystroke, gait, or
other physical patterns, and sleep, health, or exercise data.
</td>
<td>NO</td>
</tr>
<tr>
<td>F. Internet or other similar network activity.</td>
<td>
Browsing history, search history, information on a consumer's interaction with a
website, application, or advertisement.
</td>
<td>YES</td>
</tr>
<tr>
<td>G. Geolocation data.</td>
<td>Physical location or movements.</td>
<td>YES</td>
</tr>
<tr>
<td>H. Sensory data.</td>
<td>Audio, electronic, visual, thermal, olfactory, or similar information.</td>
<td>NO</td>
</tr>
<tr>
<td>I. Professional or employment-related information.</td>
<td>Current or past job history or performance evaluations.</td>
<td>NO</td>
</tr>
<tr>
<td>
J. Non-public education information (per the Family Educational Rights and Privacy Act
(20 U.S.C. Section 1232g, 34 C.F.R. Part 99)).
</td>
<td>
Education records directly related to a student maintained by an educational institution
or party acting on its behalf, such as grades, transcripts, class lists, student
schedules, student identification codes, student financial information, or student
disciplinary records.
</td>
<td>NO</td>
</tr>
<tr>
<td>K. Inferences drawn from other personal information.</td>
<td>
Profile reflecting a person's preferences, characteristics, psychological trends,
predispositions, behavior, attitudes, intelligence, abilities, and aptitudes.
</td>
<td>NO</td>
</tr>
</tbody>
</table>
<p>Personal information does not include:</p>
<ul>
<li>Publicly available information from government records.</li>
<li>Deidentified or aggregated consumer information.</li>
<li>Information excluded from the CCPA's scope, like:</li>
<ul>
<li>
health or medical information covered by the Health Insurance Portability and
Accountability Act of 1996 (HIPAA) and the California Confidentiality of Medical
Information Act (CMIA) or clinical trial data;
</li>
<li>
personal information covered by certain sector-specific privacy laws, including the Fair
Credit Reporting Act (FRCA), the Gramm-Leach-Bliley Act (GLBA) or California Financial
Information Privacy Act (FIPA), and the Driver's Privacy Protection Act of 1994.
</li>
</ul>
</ul>
<p>
We obtain the categories of personal information listed above from the following categories of
sources:
</p>
<ul>
<li>
Directly from you. For example, from forms you complete or products and services you
purchase.
</li>
<li>Indirectly from you. For example, from observing your actions on our Service.</li>
</ul>
<h2>Information We Collect</h2>
<p>
Our Service collects information that identifies, relates to, describes, references, is
capable of being associated with, or could reasonably be linked, directly or indirectly, with
a particular consumer or device (<strong>"personal information"</strong>). In particular, our
Service has collected the following categories of personal information from its consumers
within the last twelve (12) months:
</p>
<table>
<tbody>
<tr>
<th>Category</th>
<th>Examples</th>
<th>Collected</th>
</tr>
<tr>
<td>A. Identifiers.</td>
<td>
A real name, alias, postal address, unique personal identifier, online identifier,
Internet Protocol address, email address, account name, Social Security number, driver's
license number, passport number, or other similar identifiers.
</td>
<td>YES</td>
</tr>
<tr>
<td>
B. Personal information categories listed in the California Customer Records statute
(Cal. Civ. Code § 1798.80(e)).
</td>
<td>
A name, signature, Social Security number, physical characteristics or description,
address, telephone number, passport number, driver's license or state identification
card number, insurance policy number, education, employment, employment history, bank
account number, credit card number, debit card number, or any other financial
information, medical information, or health insurance information. <br /><br />
Some personal information included in this category may overlap with other categories.
</td>
<td>YES</td>
</tr>
<tr>
<td>C. Protected classification characteristics.</td>
<td>
Age (40 years or older), race, color, ancestry, national origin, citizenship, religion
or creed, marital status, medical condition, physical or mental disability, sex
(including gender, gender identity, gender expression, pregnancy or childbirth and
related medical conditions), sexual orientation, veteran or military status, genetic
information (including familial genetic information).
</td>
<td>NO</td>
</tr>
<tr>
<td>D. Commercial information.</td>
<td>
Records of personal property, products or services purchased, obtained, or considered,
or other purchasing or consuming histories or tendencies.
</td>
<td>YES</td>
</tr>
<tr>
<td>E. Biometric information.</td>
<td>
Genetic, physiological, behavioral, and biological characteristics, or activity patterns
used to extract a template or other identifier or identifying information, such as,
fingerprints, faceprints, and voiceprints, iris or retina scans, keystroke, gait, or
other physical patterns, and sleep, health, or exercise data.
</td>
<td>NO</td>
</tr>
<tr>
<td>F. Internet or other similar network activity.</td>
<td>
Browsing history, search history, information on a consumer's interaction with a
website, application, or advertisement.
</td>
<td>YES</td>
</tr>
<tr>
<td>G. Geolocation data.</td>
<td>Physical location or movements.</td>
<td>YES</td>
</tr>
<tr>
<td>H. Sensory data.</td>
<td>Audio, electronic, visual, thermal, olfactory, or similar information.</td>
<td>NO</td>
</tr>
<tr>
<td>I. Professional or employment-related information.</td>
<td>Current or past job history or performance evaluations.</td>
<td>NO</td>
</tr>
<tr>
<td>
J. Non-public education information (per the Family Educational Rights and Privacy Act
(20 U.S.C. Section 1232g, 34 C.F.R. Part 99)).
</td>
<td>
Education records directly related to a student maintained by an educational institution
or party acting on its behalf, such as grades, transcripts, class lists, student
schedules, student identification codes, student financial information, or student
disciplinary records.
</td>
<td>NO</td>
</tr>
<tr>
<td>K. Inferences drawn from other personal information.</td>
<td>
Profile reflecting a person's preferences, characteristics, psychological trends,
predispositions, behavior, attitudes, intelligence, abilities, and aptitudes.
</td>
<td>NO</td>
</tr>
</tbody>
</table>
<p>Personal information does not include:</p>
<ul>
<li>Publicly available information from government records.</li>
<li>Deidentified or aggregated consumer information.</li>
<li>Information excluded from the CCPA's scope, like:</li>
<ul>
<li>
health or medical information covered by the Health Insurance Portability and
Accountability Act of 1996 (HIPAA) and the California Confidentiality of Medical
Information Act (CMIA) or clinical trial data;
</li>
<li>
personal information covered by certain sector-specific privacy laws, including the Fair
Credit Reporting Act (FRCA), the Gramm-Leach-Bliley Act (GLBA) or California Financial
Information Privacy Act (FIPA), and the Driver's Privacy Protection Act of 1994.
</li>
</ul>
</ul>
<p>
We obtain the categories of personal information listed above from the following categories of
sources:
</p>
<ul>
<li>
Directly from you. For example, from forms you complete or products and services you
purchase.
</li>
<li>Indirectly from you. For example, from observing your actions on our Service.</li>
</ul>
<h2>Use of Personal Information</h2>
<p>
We may use or disclose the personal information we collect for one or more of the following
business purposes:
</p>
<ul>
<li>
To fulfill or meet the reason you provided the information. For example, if you share your
name and contact information to request a price quote or ask a question about our products
or services, we will use that personal information to respond to your inquiry. If you
provide your personal information to purchase a product or service, we will use that
information to process your payment and facilitate delivery. We may also save your
information to facilitate new product orders or process returns.
</li>
<li>To provide, support, personalize, and develop our Service, products, and services.</li>
<li>To create, maintain, customize, and secure your account with us.</li>
<li>
To process your requests, purchases, transactions, and payments and prevent transactional
fraud.
</li>
<li>
To provide you with support and to respond to your inquiries, including to investigate and
address your concerns and monitor and improve our responses.
</li>
<li>
To personalize your Service experience and to deliver content and product and service
offerings relevant to your interests, including targeted offers and ads through our Service,
third-party sites, and via email or text message (with your consent, where required by law).
</li>
<li>
To help maintain the safety, security, and integrity of our Service, products and services,
databases and other technology assets, and business.
</li>
<li>
For testing, research, analysis, and product development, including to develop and improve
our Service, products, and services.
</li>
<li>
To respond to law enforcement requests and as required by applicable law, court order, or
governmental regulations.
</li>
<li>
As described to you when collecting your personal information or as otherwise set forth in
the CCPA.
</li>
<li>
To evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or
other sale or transfer of some or all of the Company's assets, whether as a going concern or
as part of bankruptcy, liquidation, or similar proceeding, in which personal information
held by the Company about our Service users is among the assets transferred.
</li>
</ul>
<p>
We will not collect additional categories of personal information or use the personal
information we collected for materially different, unrelated, or incompatible purposes without
providing you notice.
</p>
<h2>Use of Personal Information</h2>
<p>
We may use or disclose the personal information we collect for one or more of the following
business purposes:
</p>
<ul>
<li>
To fulfill or meet the reason you provided the information. For example, if you share your
name and contact information to request a price quote or ask a question about our products
or services, we will use that personal information to respond to your inquiry. If you
provide your personal information to purchase a product or service, we will use that
information to process your payment and facilitate delivery. We may also save your
information to facilitate new product orders or process returns.
</li>
<li>To provide, support, personalize, and develop our Service, products, and services.</li>
<li>To create, maintain, customize, and secure your account with us.</li>
<li>
To process your requests, purchases, transactions, and payments and prevent transactional
fraud.
</li>
<li>
To provide you with support and to respond to your inquiries, including to investigate and
address your concerns and monitor and improve our responses.
</li>
<li>
To personalize your Service experience and to deliver content and product and service
offerings relevant to your interests, including targeted offers and ads through our Service,
third-party sites, and via email or text message (with your consent, where required by law).
</li>
<li>
To help maintain the safety, security, and integrity of our Service, products and services,
databases and other technology assets, and business.
</li>
<li>
For testing, research, analysis, and product development, including to develop and improve
our Service, products, and services.
</li>
<li>
To respond to law enforcement requests and as required by applicable law, court order, or
governmental regulations.
</li>
<li>
As described to you when collecting your personal information or as otherwise set forth in
the CCPA.
</li>
<li>
To evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or
other sale or transfer of some or all of the Company's assets, whether as a going concern or
as part of bankruptcy, liquidation, or similar proceeding, in which personal information
held by the Company about our Service users is among the assets transferred.
</li>
</ul>
<p>
We will not collect additional categories of personal information or use the personal
information we collected for materially different, unrelated, or incompatible purposes without
providing you notice.
</p>
<h2>Sharing Personal Information</h2>
<p>
We may disclose your personal information to a third party for a business purpose. When we
disclose personal information for a business purpose, we enter a contract that describes the
purpose and requires the recipient to both keep that personal information confidential and not
use it for any purpose except performing the contract. The CCPA prohibits third parties who
purchase the personal information we hold from reselling it unless you have received explicit
notice and an opportunity to opt-out of further sales.
</p>
<h2>Sharing Personal Information</h2>
<p>
We may disclose your personal information to a third party for a business purpose. When we
disclose personal information for a business purpose, we enter a contract that describes the
purpose and requires the recipient to both keep that personal information confidential and not
use it for any purpose except performing the contract. The CCPA prohibits third parties who
purchase the personal information we hold from reselling it unless you have received explicit
notice and an opportunity to opt-out of further sales.
</p>
<h3>Disclosures of Personal Information for a Business Purpose</h3>
<p>
In the preceding twelve (12) months, Company has disclosed the following categories of
personal information for a business purpose:
</p>
<ul>
<li>Category A: Identifiers.</li>
<li>Category F: Internet or other similar network activity.</li>
<li>Category G: Geolocation data.</li>
</ul>
<p>
We disclose your personal information for a business purpose to the following categories of
third parties:
</p>
<ul>
<li>Service providers.</li>
</ul>
<h3>Disclosures of Personal Information for a Business Purpose</h3>
<p>
In the preceding twelve (12) months, Company has disclosed the following categories of
personal information for a business purpose:
</p>
<ul>
<li>Category A: Identifiers.</li>
<li>Category F: Internet or other similar network activity.</li>
<li>Category G: Geolocation data.</li>
</ul>
<p>
We disclose your personal information for a business purpose to the following categories of
third parties:
</p>
<ul>
<li>Service providers.</li>
</ul>
<h3>Sales of Personal Information</h3>
<p>In the preceding twelve (12) months, Company has not sold personal information.</p>
<h3>Sales of Personal Information</h3>
<p>In the preceding twelve (12) months, Company has not sold personal information.</p>
<h2>Your Rights and Choices</h2>
<p>
The CCPA provides consumers (California residents) with specific rights regarding their
personal information. This section describes your CCPA rights and explains how to exercise
those rights.
</p>
<h2>Your Rights and Choices</h2>
<p>
The CCPA provides consumers (California residents) with specific rights regarding their
personal information. This section describes your CCPA rights and explains how to exercise
those rights.
</p>
<h3>Access to Specific Information and Data Portability Rights</h3>
<p>
You have the right to request that we disclose certain information to you about our collection
and use of your personal information over the past 12 months. Once we receive and confirm your
verifiable consumer request (see
<i>Exercising Access, Data Portability, and Deletion Rights</i>), we will disclose to you:
</p>
<ul>
<li>The categories of personal information we collected about you.</li>
<li>The categories of sources for the personal information we collected about you.</li>
<li>
Our business or commercial purpose for collecting or selling that personal information.
</li>
<li>The categories of third parties with whom we share that personal information.</li>
<li>
The specific pieces of personal information we collected about you (also called a data
portability request).
</li>
<li>
If we sold or disclosed your personal information for a business purpose, two separate lists
disclosing:
</li>
<ul>
<li>
sales, identifying the personal information categories that each category of recipient
purchased; and
</li>
<li>
disclosures for a business purpose, identifying the personal information categories that
each category of recipient obtained.
</li>
</ul>
</ul>
<h3>Access to Specific Information and Data Portability Rights</h3>
<p>
You have the right to request that we disclose certain information to you about our collection
and use of your personal information over the past 12 months. Once we receive and confirm your
verifiable consumer request (see
<i>Exercising Access, Data Portability, and Deletion Rights</i>), we will disclose to you:
</p>
<ul>
<li>The categories of personal information we collected about you.</li>
<li>The categories of sources for the personal information we collected about you.</li>
<li>
Our business or commercial purpose for collecting or selling that personal information.
</li>
<li>The categories of third parties with whom we share that personal information.</li>
<li>
The specific pieces of personal information we collected about you (also called a data
portability request).
</li>
<li>
If we sold or disclosed your personal information for a business purpose, two separate lists
disclosing:
</li>
<ul>
<li>
sales, identifying the personal information categories that each category of recipient
purchased; and
</li>
<li>
disclosures for a business purpose, identifying the personal information categories that
each category of recipient obtained.
</li>
</ul>
</ul>
<h3>Deletion Request Rights</h3>
<p>
You have the right to request that we delete any of your personal information that we
collected from you and retained, subject to certain exceptions. Once we receive and confirm
your verifiable consumer request (see
<i>Exercising Access, Data Portability, and Deletion Rights</i>), we will delete (and direct
our service providers to delete) your personal information from our records, unless an
exception applies.
</p>
<p>
We may deny your deletion request if retaining the information is necessary for us or our
service provider(s) to:
</p>
<ol>
<li>
Complete the transaction for which we collected the personal information, provide a good or
service that you requested, take actions reasonably anticipated within the context of our
ongoing business relationship with you, or otherwise perform our contract with you.
</li>
<li>
Detect security incidents, protect against malicious, deceptive, fraudulent, or illegal
activity, or prosecute those responsible for such activities.
</li>
<li>
Debug products to identify and repair errors that impair existing intended functionality.
</li>
<li>
Exercise free speech, ensure the right of another consumer to exercise their free speech
rights, or exercise another right provided for by law.
</li>
<li>
Comply with the California Electronic Communications Privacy Act (Cal. Penal Code § 1546
<i>et. seq.</i>).
</li>
<li>
Engage in public or peer-reviewed scientific, historical, or statistical research in the
public interest that adheres to all other applicable ethics and privacy laws, when the
information's deletion may likely render impossible or seriously impair the research's
achievement, if you previously provided informed consent.
</li>
<li>
Enable solely internal uses that are reasonably aligned with consumer expectations based on
your relationship with us.
</li>
<li>Comply with a legal obligation.</li>
<li>
Make other internal and lawful uses of that information that are compatible with the context
in which you provided it.
</li>
</ol>
<h3>Deletion Request Rights</h3>
<p>
You have the right to request that we delete any of your personal information that we
collected from you and retained, subject to certain exceptions. Once we receive and confirm
your verifiable consumer request (see
<i>Exercising Access, Data Portability, and Deletion Rights</i>), we will delete (and direct
our service providers to delete) your personal information from our records, unless an
exception applies.
</p>
<p>
We may deny your deletion request if retaining the information is necessary for us or our
service provider(s) to:
</p>
<ol>
<li>
Complete the transaction for which we collected the personal information, provide a good or
service that you requested, take actions reasonably anticipated within the context of our
ongoing business relationship with you, or otherwise perform our contract with you.
</li>
<li>
Detect security incidents, protect against malicious, deceptive, fraudulent, or illegal
activity, or prosecute those responsible for such activities.
</li>
<li>
Debug products to identify and repair errors that impair existing intended functionality.
</li>
<li>
Exercise free speech, ensure the right of another consumer to exercise their free speech
rights, or exercise another right provided for by law.
</li>
<li>
Comply with the California Electronic Communications Privacy Act (Cal. Penal Code § 1546
<i>et. seq.</i>).
</li>
<li>
Engage in public or peer-reviewed scientific, historical, or statistical research in the
public interest that adheres to all other applicable ethics and privacy laws, when the
information's deletion may likely render impossible or seriously impair the research's
achievement, if you previously provided informed consent.
</li>
<li>
Enable solely internal uses that are reasonably aligned with consumer expectations based on
your relationship with us.
</li>
<li>Comply with a legal obligation.</li>
<li>
Make other internal and lawful uses of that information that are compatible with the context
in which you provided it.
</li>
</ol>
<h3>Exercising Access, Data Portability, and Deletion Rights</h3>
<p>
To exercise the access, data portability, and deletion rights described above, please submit a
verifiable consumer request to us by emailing us at
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
<p>
Only you, or a person registered with the California Secretary of State that you authorize to
act on your behalf, may make a verifiable consumer request related to your personal
information. You may also make a verifiable consumer request on behalf of your minor child.
</p>
<p>
You may only make a verifiable consumer request for access or data portability twice within a
12-month period. The verifiable consumer request must:
</p>
<ul>
<li>
Provide sufficient information that allows us to reasonably verify you are the person about
whom we collected personal information or an authorized representative.
</li>
<li>
Describe your request with sufficient detail that allows us to properly understand,
evaluate, and respond to it.
</li>
</ul>
<p>
We cannot respond to your request or provide you with personal information if we cannot verify
your identity or authority to make the request and confirm the personal information relates to
you.
</p>
<p>
Making a verifiable consumer request does not require you to create an account with us.
However, we do consider requests made through your password protected account sufficiently
verified when the request relates to personal information associated with that specific
account.
</p>
<p>
We will only use personal information provided in a verifiable consumer request to verify the
requestor's identity or authority to make the request.
</p>
<p>
For instructions on exercising sale opt-out rights, see
<i>Personal Information Sales Opt-Out and Opt-In Rights.</i>
</p>
<h3>Exercising Access, Data Portability, and Deletion Rights</h3>
<p>
To exercise the access, data portability, and deletion rights described above, please submit a
verifiable consumer request to us by emailing us at
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
<p>
Only you, or a person registered with the California Secretary of State that you authorize to
act on your behalf, may make a verifiable consumer request related to your personal
information. You may also make a verifiable consumer request on behalf of your minor child.
</p>
<p>
You may only make a verifiable consumer request for access or data portability twice within a
12-month period. The verifiable consumer request must:
</p>
<ul>
<li>
Provide sufficient information that allows us to reasonably verify you are the person about
whom we collected personal information or an authorized representative.
</li>
<li>
Describe your request with sufficient detail that allows us to properly understand,
evaluate, and respond to it.
</li>
</ul>
<p>
We cannot respond to your request or provide you with personal information if we cannot verify
your identity or authority to make the request and confirm the personal information relates to
you.
</p>
<p>
Making a verifiable consumer request does not require you to create an account with us.
However, we do consider requests made through your password protected account sufficiently
verified when the request relates to personal information associated with that specific
account.
</p>
<p>
We will only use personal information provided in a verifiable consumer request to verify the
requestor's identity or authority to make the request.
</p>
<p>
For instructions on exercising sale opt-out rights, see
<i>Personal Information Sales Opt-Out and Opt-In Rights.</i>
</p>
<h3>Response Timing and Format</h3>
<p>
We endeavor to respond to a verifiable consumer request within forty-five (45) days of its
receipt. If we require more time (up to 90 days), we will inform you of the reason and
extension period in writing.
</p>
<p>
If you have an account with us, we will deliver our written response to that account. If you
do not have an account with us, we will deliver our written response by mail or
electronically, at your option.
</p>
<p>
Any disclosures we provide will only cover the 12-month period preceding the verifiable
consumer request's receipt. The response we provide will also explain the reasons we cannot
comply with a request, if applicable. For data portability requests, we will select a format
to provide your personal information that is readily useable and should allow you to transmit
the information from one entity to another entity without hindrance.
</p>
<p>
We do not charge a fee to process or respond to your verifiable consumer request unless it is
excessive, repetitive, or manifestly unfounded. If we determine that the request warrants a
fee, we will tell you why we made that decision and provide you with a cost estimate before
completing your request.
</p>
<h3>Response Timing and Format</h3>
<p>
We endeavor to respond to a verifiable consumer request within forty-five (45) days of its
receipt. If we require more time (up to 90 days), we will inform you of the reason and
extension period in writing.
</p>
<p>
If you have an account with us, we will deliver our written response to that account. If you
do not have an account with us, we will deliver our written response by mail or
electronically, at your option.
</p>
<p>
Any disclosures we provide will only cover the 12-month period preceding the verifiable
consumer request's receipt. The response we provide will also explain the reasons we cannot
comply with a request, if applicable. For data portability requests, we will select a format
to provide your personal information that is readily useable and should allow you to transmit
the information from one entity to another entity without hindrance.
</p>
<p>
We do not charge a fee to process or respond to your verifiable consumer request unless it is
excessive, repetitive, or manifestly unfounded. If we determine that the request warrants a
fee, we will tell you why we made that decision and provide you with a cost estimate before
completing your request.
</p>
<h2>Non-Discrimination</h2>
<p>
We will not discriminate against you for exercising any of your CCPA rights. Unless permitted
by the CCPA, we will not:
</p>
<ul>
<li>Deny you goods or services.</li>
<li>
Charge you different prices or rates for goods or services, including through granting
discounts or other benefits, or imposing penalties.
</li>
<li>Provide you a different level or quality of goods or services.</li>
<li>
Suggest that you may receive a different price or rate for goods or services or a different
level or quality of goods or services.
</li>
</ul>
<p>
However, we may offer you certain financial incentives permitted by the CCPA that
<strong>can result</strong>
in different prices, rates, or quality levels. Any CCPA-permitted financial incentive we offer
will reasonably relate to your personal information's value and contain written terms that
describe the program's material aspects. Participation in a financial incentive program
requires your prior opt in consent, which you may revoke at any time.
</p>
<h2>Changes to Our Privacy Notice</h2>
<p>
We reserve the right to amend this privacy notice at our discretion and at any time. When we
make changes to this privacy notice, we will post the updated notice on the Service and update
the notice's effective date.
<strong
>Your continued use of our Service following the posting of changes constitutes your
acceptance of such changes.
</strong>
</p>
<h2>Contact Information</h2>
<p>
If you have any questions or comments about this notice, the ways in which we collect and use
your information described below and in the
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>, your choices and rights regarding
such use, or wish to exercise your rights under California law, please do not hesitate to
contact us at <a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
</div>
<h2>Non-Discrimination</h2>
<p>
We will not discriminate against you for exercising any of your CCPA rights. Unless permitted
by the CCPA, we will not:
</p>
<ul>
<li>Deny you goods or services.</li>
<li>
Charge you different prices or rates for goods or services, including through granting
discounts or other benefits, or imposing penalties.
</li>
<li>Provide you a different level or quality of goods or services.</li>
<li>
Suggest that you may receive a different price or rate for goods or services or a different
level or quality of goods or services.
</li>
</ul>
<p>
However, we may offer you certain financial incentives permitted by the CCPA that
<strong>can result</strong>
in different prices, rates, or quality levels. Any CCPA-permitted financial incentive we offer
will reasonably relate to your personal information's value and contain written terms that
describe the program's material aspects. Participation in a financial incentive program
requires your prior opt in consent, which you may revoke at any time.
</p>
<h2>Changes to Our Privacy Notice</h2>
<p>
We reserve the right to amend this privacy notice at our discretion and at any time. When we
make changes to this privacy notice, we will post the updated notice on the Service and update
the notice's effective date.
<strong
>Your continued use of our Service following the posting of changes constitutes your
acceptance of such changes.
</strong>
</p>
<h2>Contact Information</h2>
<p>
If you have any questions or comments about this notice, the ways in which we collect and use
your information described below and in the
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>, your choices and rights regarding
such use, or wish to exercise your rights under California law, please do not hesitate to
contact us at <a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
</div>
</template>
<script setup>
const description =
"The California Privacy Notice of Modrinth, an open source modding platform focused on Minecraft.";
'The California Privacy Notice of Modrinth, an open source modding platform focused on Minecraft.'
useSeoMeta({
title: "California Privacy Notice - Modrinth",
description,
ogTitle: "California Privacy Notice",
ogDescription: description,
});
title: 'California Privacy Notice - Modrinth',
description,
ogTitle: 'California Privacy Notice',
ogDescription: description,
})
</script>

View File

@@ -1,192 +1,192 @@
<template>
<div class="markdown-body">
<h1>Rewards Program Information</h1>
<p><em>Last modified: Feb 20, 2025</em></p>
<p>
This page was created for transparency for how the rewards program works on Modrinth. Feel
free to join our Discord or email
<a href="mailto:support@modrinth.com">support@modrinth.com</a> if you have any questions!
</p>
<p>
This document is provided for informational purposes only and does not constitute a legal
agreement. Modrinth makes no representations or warranties as to the accuracy, completeness,
or reliability of the information contained herein.
</p>
<h2>Rewards Distribution</h2>
<p>
We collect ad revenue on our website and app through our ad network
<a href="https://adrinth.com">Adrinth</a>, which is powered by
<a href="https://aditude.io">Aditude</a>. We then distribute this ad revenue to creators.
</p>
<p>
The advertising revenue of the entire website and app is split 75% to creators and 25% to
Modrinth.
</p>
<p>
The creator allotment to the pool is decided by how many page views and in-app downloads your
project receives (user profiles are not used in this calculation). Each page view and in-app
download counts as a "point". Then, the money is distributed based on each author's point
earnings daily.
</p>
<p>For example, consider this test scenario (all numbers are fake):</p>
<ul>
<li>The site earns $100 on a day.</li>
<li>User A has the projects: NoobCraft and Minesweeper</li>
<li>NoobCraft receives 10 page views and 30 in-app downloads (40 points)</li>
<li>Minesweeper receives 100 page views and 10 in-app downloads (110 points)</li>
<li>
User B and C both co-own these projects: Bloxcraft and Craftnite. They split their payouts
40/60.
</li>
<li>Bloxcraft receives 50 page views and 20 in-app downloads (70 points)</li>
<li>Craftnite receives 10 page views and 0 in-app downloads (10 points)</li>
</ul>
<p>In this scenario, the earnings for each creator and Modrinth would be as follows:</p>
<ul>
<li>Modrinth: $25 (25% of $100, the site's earnings for the day)</li>
<li>User A: $48.91 ($75 * (10 + 30 + 100 + 10)/230)</li>
<li>User B: $10.43 (0.4 * $75 * (50 + 20 + 10 + 0)/230)</li>
<li>User C: $15.65 (0.6 * $75 * (50 + 20 + 10 + 0)/230)</li>
<li>Note: 230 is the sum of all page views and in-app downloads from above</li>
</ul>
<p>
Page views are counted when a legitimate browser views a project page. In-app downloads when a
user logged into the launcher downloads a project. Project downloads alongside modpack
downloads are counted equally. In each category, Modrinth actively removes botted downloads
and page views at our own discretion. If users are caught botting, they will be permanently
banned from using Modrinth's services.
</p>
<p>
You can view your page views and project downloads in your
<a href="https://modrinth.com/dashboard/analytics">analytics dashboard</a>.
</p>
<h2>Frequently Asked Questions</h2>
<p>
This section covers some common concerns people have about our monetization program. If you
have more, feel free to join our Discord or contact support.
</p>
<h3>Do you have to enroll in the monetization program to get money?</h3>
<p>
No. All creators who upload to Modrinth automatically will receive funds as according to the
above algorithm. However, if you would like to withdraw money from your account, you must
enroll by adding your payment information.
</p>
<h3>What methods can I use withdraw money from my account? Are there any fees?</h3>
<p>
Right now, you can use PayPal or Venmo to withdraw money from your Modrinth account. Gift card
withdrawal is also available. We are working on more methods to withdraw money from your
account. There are fees to withdraw money from your Modrinth account—see the revenue page in
your dashboard for more information.
</p>
<h3 id="pending">What does "pending" revenue mean in my dashboard?</h3>
<p>
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
revenue is immediately available to withdraw. We pay creators as soon as we receive the money
from our ad providers, which is 60 days after the last day of each month.
</p>
<div class="markdown-body">
<h1>Rewards Program Information</h1>
<p><em>Last modified: Feb 20, 2025</em></p>
<p>
This page was created for transparency for how the rewards program works on Modrinth. Feel
free to join our Discord or email
<a href="mailto:support@modrinth.com">support@modrinth.com</a> if you have any questions!
</p>
<p>
This document is provided for informational purposes only and does not constitute a legal
agreement. Modrinth makes no representations or warranties as to the accuracy, completeness,
or reliability of the information contained herein.
</p>
<h2>Rewards Distribution</h2>
<p>
We collect ad revenue on our website and app through our ad network
<a href="https://adrinth.com">Adrinth</a>, which is powered by
<a href="https://aditude.io">Aditude</a>. We then distribute this ad revenue to creators.
</p>
<p>
The advertising revenue of the entire website and app is split 75% to creators and 25% to
Modrinth.
</p>
<p>
The creator allotment to the pool is decided by how many page views and in-app downloads your
project receives (user profiles are not used in this calculation). Each page view and in-app
download counts as a "point". Then, the money is distributed based on each author's point
earnings daily.
</p>
<p>For example, consider this test scenario (all numbers are fake):</p>
<ul>
<li>The site earns $100 on a day.</li>
<li>User A has the projects: NoobCraft and Minesweeper</li>
<li>NoobCraft receives 10 page views and 30 in-app downloads (40 points)</li>
<li>Minesweeper receives 100 page views and 10 in-app downloads (110 points)</li>
<li>
User B and C both co-own these projects: Bloxcraft and Craftnite. They split their payouts
40/60.
</li>
<li>Bloxcraft receives 50 page views and 20 in-app downloads (70 points)</li>
<li>Craftnite receives 10 page views and 0 in-app downloads (10 points)</li>
</ul>
<p>In this scenario, the earnings for each creator and Modrinth would be as follows:</p>
<ul>
<li>Modrinth: $25 (25% of $100, the site's earnings for the day)</li>
<li>User A: $48.91 ($75 * (10 + 30 + 100 + 10)/230)</li>
<li>User B: $10.43 (0.4 * $75 * (50 + 20 + 10 + 0)/230)</li>
<li>User C: $15.65 (0.6 * $75 * (50 + 20 + 10 + 0)/230)</li>
<li>Note: 230 is the sum of all page views and in-app downloads from above</li>
</ul>
<p>
Page views are counted when a legitimate browser views a project page. In-app downloads when a
user logged into the launcher downloads a project. Project downloads alongside modpack
downloads are counted equally. In each category, Modrinth actively removes botted downloads
and page views at our own discretion. If users are caught botting, they will be permanently
banned from using Modrinth's services.
</p>
<p>
You can view your page views and project downloads in your
<a href="https://modrinth.com/dashboard/analytics">analytics dashboard</a>.
</p>
<h2>Frequently Asked Questions</h2>
<p>
This section covers some common concerns people have about our monetization program. If you
have more, feel free to join our Discord or contact support.
</p>
<h3>Do you have to enroll in the monetization program to get money?</h3>
<p>
No. All creators who upload to Modrinth automatically will receive funds as according to the
above algorithm. However, if you would like to withdraw money from your account, you must
enroll by adding your payment information.
</p>
<h3>What methods can I use withdraw money from my account? Are there any fees?</h3>
<p>
Right now, you can use PayPal or Venmo to withdraw money from your Modrinth account. Gift card
withdrawal is also available. We are working on more methods to withdraw money from your
account. There are fees to withdraw money from your Modrinth account—see the revenue page in
your dashboard for more information.
</p>
<h3 id="pending">What does "pending" revenue mean in my dashboard?</h3>
<p>
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
revenue is immediately available to withdraw. We pay creators as soon as we receive the money
from our ad providers, which is 60 days after the last day of each month.
</p>
<p>
To understand when revenue becomes available, you can use this calculator to estimate when
revenue earned on a specific date will be available for withdrawal. Please be advised that all
dates within this calculator are represented at 00:00 UTC.
</p>
<p>
To understand when revenue becomes available, you can use this calculator to estimate when
revenue earned on a specific date will be available for withdrawal. Please be advised that all
dates within this calculator are represented at 00:00 UTC.
</p>
<table>
<thead>
<tr>
<th>Timeline</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr>
<td>Revenue earned on</td>
<td>
<input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<noscript
>(JavaScript must be enabled for the date picker to function, example date:
2024-07-15)
</noscript>
</td>
</tr>
<tr>
<td>End of the month</td>
<td>{{ formatDate(endOfMonthDate) }}</td>
</tr>
<tr>
<td>NET 60 policy applied</td>
<td>+ 60 days</td>
</tr>
<tr class="final-result">
<td>Available for withdrawal</td>
<td>{{ formatDate(withdrawalDate) }}</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Timeline</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr>
<td>Revenue earned on</td>
<td>
<input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<noscript
>(JavaScript must be enabled for the date picker to function, example date:
2024-07-15)
</noscript>
</td>
</tr>
<tr>
<td>End of the month</td>
<td>{{ formatDate(endOfMonthDate) }}</td>
</tr>
<tr>
<td>NET 60 policy applied</td>
<td>+ 60 days</td>
</tr>
<tr class="final-result">
<td>Available for withdrawal</td>
<td>{{ formatDate(withdrawalDate) }}</td>
</tr>
</tbody>
</table>
<h3>How do I know Modrinth is being transparent about revenue?</h3>
<p>
We aim to be as transparent as possible with creator revenue. All of our code is open source,
including our
<a href="https://github.com/modrinth/code/blob/main/apps/labrinth/src/queue/payouts.rs#L598">
revenue distribution system</a
>. We also have an
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>
to query the exact daily advertising revenue for the site - so far, creators on Modrinth have
earned a total of <strong>{{ formatMoney(platformRevenue) }}</strong> in ad revenue.
</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Revenue</th>
<th>Creator Revenue (75%)</th>
<th>Modrinth's Cut (25%)</th>
</tr>
</thead>
<tbody>
<tr v-for="item in platformRevenueData" :key="item.time">
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
<td>{{ formatMoney(Number(item.revenue) + Number(item.creator_revenue)) }}</td>
<td>{{ formatMoney(Number(item.creator_revenue)) }}</td>
<td>{{ formatMoney(Number(item.revenue)) }}</td>
</tr>
</tbody>
</table>
<small
>Modrinth's total ad revenue in the previous 5 days, for the entire dataset, use the
aforementioned
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
>
</div>
<h3>How do I know Modrinth is being transparent about revenue?</h3>
<p>
We aim to be as transparent as possible with creator revenue. All of our code is open source,
including our
<a href="https://github.com/modrinth/code/blob/main/apps/labrinth/src/queue/payouts.rs#L598">
revenue distribution system</a
>. We also have an
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>
to query the exact daily advertising revenue for the site - so far, creators on Modrinth have
earned a total of <strong>{{ formatMoney(platformRevenue) }}</strong> in ad revenue.
</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Revenue</th>
<th>Creator Revenue (75%)</th>
<th>Modrinth's Cut (25%)</th>
</tr>
</thead>
<tbody>
<tr v-for="item in platformRevenueData" :key="item.time">
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
<td>{{ formatMoney(Number(item.revenue) + Number(item.creator_revenue)) }}</td>
<td>{{ formatMoney(Number(item.creator_revenue)) }}</td>
<td>{{ formatMoney(Number(item.revenue)) }}</td>
</tr>
</tbody>
</table>
<small
>Modrinth's total ad revenue in the previous 5 days, for the entire dataset, use the
aforementioned
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
>
</div>
</template>
<script lang="ts" setup>
import dayjs from "dayjs";
import { computed, ref } from "vue";
import { formatDate, formatMoney } from "@modrinth/utils";
import { formatDate, formatMoney } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
const description =
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
'Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.'
useSeoMeta({
title: "Rewards Program Information - Modrinth",
description,
ogTitle: "Rewards Program Information",
ogDescription: description,
});
title: 'Rewards Program Information - Modrinth',
description,
ogTitle: 'Rewards Program Information',
ogDescription: description,
})
const rawSelectedDate = ref(dayjs().format("YYYY-MM-DD"));
const selectedDate = computed(() => dayjs(rawSelectedDate.value));
const endOfMonthDate = computed(() => selectedDate.value.endOf("month"));
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, "days"));
const rawSelectedDate = ref(dayjs().format('YYYY-MM-DD'))
const selectedDate = computed(() => dayjs(rawSelectedDate.value))
const endOfMonthDate = computed(() => selectedDate.value.endOf('month'))
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, 'days'))
const { data: transparencyInformation } = await useAsyncData("payout/platform_revenue", () =>
useBaseFetch("payout/platform_revenue", {
apiVersion: 3,
}),
);
const { data: transparencyInformation } = await useAsyncData('payout/platform_revenue', () =>
useBaseFetch('payout/platform_revenue', {
apiVersion: 3,
}),
)
const platformRevenue = (transparencyInformation.value as any)?.all_time;
const platformRevenueData = (transparencyInformation.value as any)?.data?.slice(0, 5) ?? [];
const platformRevenue = (transparencyInformation.value as any)?.all_time
const platformRevenueData = (transparencyInformation.value as any)?.data?.slice(0, 5) ?? []
</script>

View File

@@ -1,86 +1,86 @@
<template>
<div class="markdown-body">
<h1>Rewards Program Terms</h1>
<p>
These REWARDS PROGRAM TERMS ("Terms") constitute a legally binding agreement between you (or
the entity you represent) ("you") and Rinth, Inc. ("Rinth") concerning your participation in
the Modrinth Rewards Program (the "Rewards Program").
</p>
<p>
The Rewards Program provides developers and content creators an opportunity to monetize the
projects ("Projects") that they upload to the Modrinth website.
</p>
<p>
These Terms are in addition to and do not in any manner limit the applicability of the
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link>, the
<nuxt-link to="/legal/rules">Content Rules</nuxt-link>, or the
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>.
</p>
<div class="markdown-body">
<h1>Rewards Program Terms</h1>
<p>
These REWARDS PROGRAM TERMS ("Terms") constitute a legally binding agreement between you (or
the entity you represent) ("you") and Rinth, Inc. ("Rinth") concerning your participation in
the Modrinth Rewards Program (the "Rewards Program").
</p>
<p>
The Rewards Program provides developers and content creators an opportunity to monetize the
projects ("Projects") that they upload to the Modrinth website.
</p>
<p>
These Terms are in addition to and do not in any manner limit the applicability of the
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link>, the
<nuxt-link to="/legal/rules">Content Rules</nuxt-link>, or the
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>.
</p>
<h2>Economics</h2>
<p>
Rinth shall pay to you the percentage set forth
<nuxt-link to="/legal/cmp-info">here</nuxt-link> of net revenue collected by Rinth
attributable to ad impressions displayed on modrinth.com and the Modrinth App excluding
transaction fees ("Revenue Share"). Rinth shall make Revenue Share payments to you when you
withdraw funds from Rinth's dashboard. Rinth shall include with each such payment either
access to a dashboard or other reasonable reporting detailing the calculation thereof.
</p>
<h2>Economics</h2>
<p>
Rinth shall pay to you the percentage set forth
<nuxt-link to="/legal/cmp-info">here</nuxt-link> of net revenue collected by Rinth
attributable to ad impressions displayed on modrinth.com and the Modrinth App excluding
transaction fees ("Revenue Share"). Rinth shall make Revenue Share payments to you when you
withdraw funds from Rinth's dashboard. Rinth shall include with each such payment either
access to a dashboard or other reasonable reporting detailing the calculation thereof.
</p>
<h2>Relationship</h2>
<p>
Your relationship with Rinth relating to the Rewards Program is that of an independent
contractor. In participating in the Rewards Program, you will not be deemed an employee of
Rinth, you are not eligible for any Rinth employee benefits, and you are solely responsible
for determining and paying any taxes applicable to amounts paid to you by Rinth hereunder. You
agree to indemnify and hold harmless Rinth from and against any claim that Rinth is
responsible for payment of any such taxes.
</p>
<h2>Relationship</h2>
<p>
Your relationship with Rinth relating to the Rewards Program is that of an independent
contractor. In participating in the Rewards Program, you will not be deemed an employee of
Rinth, you are not eligible for any Rinth employee benefits, and you are solely responsible
for determining and paying any taxes applicable to amounts paid to you by Rinth hereunder. You
agree to indemnify and hold harmless Rinth from and against any claim that Rinth is
responsible for payment of any such taxes.
</p>
<h2>Disclaimer Regarding Rewards Program</h2>
<p>
YOUR PARTICIPATION IN THE REWARDS PROGRAM IS AT YOUR OWN RISK. THE REWARDS PROGRAM IS PROVIDED
ON AN "AS IS" AND "AS AVAILABLE" BASIS. TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW,
RINTH EXPRESSLY DISCLAIMS ALL WARRANTIES OF ANY KIND, WHETHER EXPRESSED OR IMPLIED, INCLUDING,
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NON-INFRINGEMENT. RINTH MAKES NO WARRANTY THAT (I) THE REWARDS PROGRAM WILL MEET
YOUR REQUIREMENTS, (II) THE REWARDS PROGRAM WILL GENERATE ANY MINIMUM REVENUE, AND/OR (III)
THE REWARDS PROGRAM WILL BE UNINTERRUPTED, TIMELY, SECURE, OR ERROR-FREE.
</p>
<h2>Disclaimer Regarding Rewards Program</h2>
<p>
YOUR PARTICIPATION IN THE REWARDS PROGRAM IS AT YOUR OWN RISK. THE REWARDS PROGRAM IS PROVIDED
ON AN "AS IS" AND "AS AVAILABLE" BASIS. TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW,
RINTH EXPRESSLY DISCLAIMS ALL WARRANTIES OF ANY KIND, WHETHER EXPRESSED OR IMPLIED, INCLUDING,
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NON-INFRINGEMENT. RINTH MAKES NO WARRANTY THAT (I) THE REWARDS PROGRAM WILL MEET
YOUR REQUIREMENTS, (II) THE REWARDS PROGRAM WILL GENERATE ANY MINIMUM REVENUE, AND/OR (III)
THE REWARDS PROGRAM WILL BE UNINTERRUPTED, TIMELY, SECURE, OR ERROR-FREE.
</p>
<h2>Limitation of Liability</h2>
<p>
YOU ACKNOWLEDGE AND AGREE THAT, TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, (A) RINTH
WILL NOT BE LIABLE TO YOU FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY
DAMAGES, WHICH YOU MAY INCUR, EVEN IF RINTH HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES, ARISING OUT OF OR IN CONNECTION WITH THE REWARDS PROGRAM OR THESE TERMS AND (B) RINTH
WILL NOT BE LIABLE TO YOU FOR MORE THAN THE AMOUNT YOU RECEIVED IN CONNECTION WITH THE REWARDS
PROGRAM IN THE SIX MONTHS PRIOR TO THE TIME YOUR CAUSE OF ACTION AROSE.
</p>
<h2>Limitation of Liability</h2>
<p>
YOU ACKNOWLEDGE AND AGREE THAT, TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, (A) RINTH
WILL NOT BE LIABLE TO YOU FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY
DAMAGES, WHICH YOU MAY INCUR, EVEN IF RINTH HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES, ARISING OUT OF OR IN CONNECTION WITH THE REWARDS PROGRAM OR THESE TERMS AND (B) RINTH
WILL NOT BE LIABLE TO YOU FOR MORE THAN THE AMOUNT YOU RECEIVED IN CONNECTION WITH THE REWARDS
PROGRAM IN THE SIX MONTHS PRIOR TO THE TIME YOUR CAUSE OF ACTION AROSE.
</p>
<h2>Governing Law</h2>
<p>
These Terms shall be governed by and construed in accordance with the internal laws of the
State of Delaware.
</p>
<h2>Governing Law</h2>
<p>
These Terms shall be governed by and construed in accordance with the internal laws of the
State of Delaware.
</p>
<h2>Termination</h2>
<p>
Rinth reserves the right, in our sole discretion and without notice or liability, to terminate
these Terms or modify or cease to offer the Rewards Program at any time, to any person, for
any reason or no reason.
</p>
</div>
<h2>Termination</h2>
<p>
Rinth reserves the right, in our sole discretion and without notice or liability, to terminate
these Terms or modify or cease to offer the Rewards Program at any time, to any person, for
any reason or no reason.
</p>
</div>
</template>
<script setup>
const description =
"The Rewards Program Terms of Modrinth, an open source modding platform focused on Minecraft.";
'The Rewards Program Terms of Modrinth, an open source modding platform focused on Minecraft.'
useSeoMeta({
title: "Rewards Program Terms - Modrinth",
description,
ogTitle: "Rewards Program Terms",
ogDescription: description,
});
title: 'Rewards Program Terms - Modrinth',
description,
ogTitle: 'Rewards Program Terms',
ogDescription: description,
})
</script>

View File

@@ -1,114 +1,114 @@
<template>
<div class="markdown-body">
<h1>Copyright Policy</h1>
<h2>Reporting Claims of Copyright Infringement</h2>
<p>
We take claims of copyright infringement seriously. We will respond to notices of alleged
copyright infringement that comply with applicable law. If you believe any materials
accessible on or from this site (the <strong>"Website"</strong>) infringe your copyright, you
may request removal of those materials (or access to them) from the Website by submitting
written notification to our copyright agent designated below. In accordance with the Online
Copyright Infringement Liability Limitation Act of the Digital Millennium Copyright Act (17
U.S.C. § 512) (<strong>"DMCA"</strong>), the written notice (the
<strong>"DMCA Notice"</strong>) must include substantially the following:
</p>
<ul>
<li>Your physical or electronic signature.</li>
<li>
Identification of the copyrighted work you believe to have been infringed or, if the claim
involves multiple works on the Website, a representative list of such works.
</li>
<li>
Identification of the material you believe to be infringing in a sufficiently precise manner
to allow us to locate that material.
</li>
<li>
Adequate information by which we can contact you (including your name, postal address,
telephone number, and, if available, email address).
</li>
<li>
A statement that you have a good faith belief that use of the copyrighted material is not
authorized by the copyright owner, its agent, or the law.
</li>
<li>A statement that the information in the written notice is accurate.</li>
<li>
A statement, under penalty of perjury, that you are authorized to act on behalf of the
copyright owner.
</li>
</ul>
<p>Our designated copyright agent to receive DMCA Notices is:</p>
<p>
&emsp;Jai Agrawal<br />
&emsp;Rinth, Inc.<br />
&emsp;410 N Scottsdale Road, Suite 1000, Tempe, Arizona, 85281<br />
&emsp;<a href="mailto:support@modrinth.com">support@modrinth.com</a><br />
</p>
<p>
If you fail to comply with all of the requirements of Section 512(c)(3) of the DMCA, your DMCA
Notice may not be effective.
</p>
<p>
Please be aware that if you knowingly materially misrepresent that material or activity on the
Website is infringing your copyright, you may be held liable for damages (including costs and
attorneys' fees) under Section 512(f) of the DMCA.
</p>
<h2>Counter Notification Procedures</h2>
<p>
If you believe that material you posted on the Website was removed or access to it was
disabled by mistake or misidentification, you may file a counter notification with us (a
<strong>"Counter Notice"</strong>) by submitting written notification to our copyright agent
designated above. Pursuant to the DMCA, the Counter Notice must include substantially the
following:
</p>
<ul>
<li>Your physical or electronic signature.</li>
<li>
An identification of the material that has been removed or to which access has been disabled
and the location at which the material appeared before it was removed or access disabled.
</li>
<li>
Adequate information by which we can contact you (including your name, postal address,
telephone number, and, if available, email address).
</li>
<li>
A statement under penalty of perjury by you that you have a good faith belief that the
material identified above was removed or disabled as a result of a mistake or
misidentification of the material to be removed or disabled.
</li>
<li>
A statement that you will consent to the jurisdiction of the Federal District Court for the
judicial district in which your address is located (or if you reside outside the United
States for any judicial district in which the Website may be found) and that you will accept
service from the person (or an agent of that person) who provided the Website with the
complaint at issue.
</li>
</ul>
<p>
The DMCA allows us to restore the removed content if the party filing the original DMCA Notice
does not file a court action against you within ten business days of receiving the copy of
your Counter Notice.
</p>
<p>
Please be aware that if you knowingly materially misrepresent that material or activity on the
Website was removed or disabled by mistake or misidentification, you may be held liable for
damages (including costs and attorneys' fees) under Section 512(f) of the DMCA.
</p>
<h2>Repeat Infringers</h2>
<p>
It is our policy in appropriate circumstances to disable and/or terminate the accounts of
users who are repeat infringers.
</p>
</div>
<div class="markdown-body">
<h1>Copyright Policy</h1>
<h2>Reporting Claims of Copyright Infringement</h2>
<p>
We take claims of copyright infringement seriously. We will respond to notices of alleged
copyright infringement that comply with applicable law. If you believe any materials
accessible on or from this site (the <strong>"Website"</strong>) infringe your copyright, you
may request removal of those materials (or access to them) from the Website by submitting
written notification to our copyright agent designated below. In accordance with the Online
Copyright Infringement Liability Limitation Act of the Digital Millennium Copyright Act (17
U.S.C. § 512) (<strong>"DMCA"</strong>), the written notice (the
<strong>"DMCA Notice"</strong>) must include substantially the following:
</p>
<ul>
<li>Your physical or electronic signature.</li>
<li>
Identification of the copyrighted work you believe to have been infringed or, if the claim
involves multiple works on the Website, a representative list of such works.
</li>
<li>
Identification of the material you believe to be infringing in a sufficiently precise manner
to allow us to locate that material.
</li>
<li>
Adequate information by which we can contact you (including your name, postal address,
telephone number, and, if available, email address).
</li>
<li>
A statement that you have a good faith belief that use of the copyrighted material is not
authorized by the copyright owner, its agent, or the law.
</li>
<li>A statement that the information in the written notice is accurate.</li>
<li>
A statement, under penalty of perjury, that you are authorized to act on behalf of the
copyright owner.
</li>
</ul>
<p>Our designated copyright agent to receive DMCA Notices is:</p>
<p>
&emsp;Jai Agrawal<br />
&emsp;Rinth, Inc.<br />
&emsp;410 N Scottsdale Road, Suite 1000, Tempe, Arizona, 85281<br />
&emsp;<a href="mailto:support@modrinth.com">support@modrinth.com</a><br />
</p>
<p>
If you fail to comply with all of the requirements of Section 512(c)(3) of the DMCA, your DMCA
Notice may not be effective.
</p>
<p>
Please be aware that if you knowingly materially misrepresent that material or activity on the
Website is infringing your copyright, you may be held liable for damages (including costs and
attorneys' fees) under Section 512(f) of the DMCA.
</p>
<h2>Counter Notification Procedures</h2>
<p>
If you believe that material you posted on the Website was removed or access to it was
disabled by mistake or misidentification, you may file a counter notification with us (a
<strong>"Counter Notice"</strong>) by submitting written notification to our copyright agent
designated above. Pursuant to the DMCA, the Counter Notice must include substantially the
following:
</p>
<ul>
<li>Your physical or electronic signature.</li>
<li>
An identification of the material that has been removed or to which access has been disabled
and the location at which the material appeared before it was removed or access disabled.
</li>
<li>
Adequate information by which we can contact you (including your name, postal address,
telephone number, and, if available, email address).
</li>
<li>
A statement under penalty of perjury by you that you have a good faith belief that the
material identified above was removed or disabled as a result of a mistake or
misidentification of the material to be removed or disabled.
</li>
<li>
A statement that you will consent to the jurisdiction of the Federal District Court for the
judicial district in which your address is located (or if you reside outside the United
States for any judicial district in which the Website may be found) and that you will accept
service from the person (or an agent of that person) who provided the Website with the
complaint at issue.
</li>
</ul>
<p>
The DMCA allows us to restore the removed content if the party filing the original DMCA Notice
does not file a court action against you within ten business days of receiving the copy of
your Counter Notice.
</p>
<p>
Please be aware that if you knowingly materially misrepresent that material or activity on the
Website was removed or disabled by mistake or misidentification, you may be held liable for
damages (including costs and attorneys' fees) under Section 512(f) of the DMCA.
</p>
<h2>Repeat Infringers</h2>
<p>
It is our policy in appropriate circumstances to disable and/or terminate the accounts of
users who are repeat infringers.
</p>
</div>
</template>
<script setup>
const description =
"The Copyright Policy of Modrinth, an open source modding platform focused on Minecraft.";
'The Copyright Policy of Modrinth, an open source modding platform focused on Minecraft.'
useSeoMeta({
title: "Copyright Policy - Modrinth",
description,
ogTitle: "Copyright Policy",
ogDescription: description,
});
title: 'Copyright Policy - Modrinth',
description,
ogTitle: 'Copyright Policy',
ogDescription: description,
})
</script>

View File

@@ -1,322 +1,322 @@
<template>
<div class="markdown-body">
<h1>Privacy Policy</h1>
<p><em>Last modified: November 17, 2023</em></p>
<div class="markdown-body">
<h1>Privacy Policy</h1>
<p><em>Last modified: November 17, 2023</em></p>
<h2>Introduction</h2>
<p>
<a href="https://modrinth.com">Modrinth</a> is part of Rinth, Inc. ("Company", "us", "we",
"our"). This privacy policy explains how we collect data, process it, and your rights relative
to your data.
</p>
<p>
This policy describes the types of information we may collect from you or that you may provide
when you use www.modrinth.com, api.modrinth.com, or the Modrinth App ("Service" or "Website"),
and our practices for collecting, using, maintaining, protecting, and disclosing that
information.
</p>
<p>This policy applies to information we collect:</p>
<ul>
<li>On this Website.</li>
<li>In email, text, and other electronic messages between you and this Website.</li>
<li>
Through mobile and desktop applications you download from this Website, which provide
dedicated non-browser-based interaction between you and this Website.
</li>
<li>
When you interact with our advertising and applications on third-party websites and
services, if those applications or advertising include links to this policy.
</li>
</ul>
<p>It does not apply to information collected by:</p>
<ul>
<li>
Us offline or through any other means, including on any other website operated by Rinth,
Inc. or any third party (including our affiliates and subsidiaries); or
</li>
<li>
Any third party (including our affiliates and subsidiaries), including through any
application or content (including advertising) that may link to or be accessible from or on
the Website
</li>
</ul>
<p>
Please read this policy carefully to understand our policies and practices regarding your
information and how we will treat it. If you do not agree with our policies and practices,
your choice is not to use our Website. By accessing or using this Website, you agree to this
privacy policy. This policy may change from time to time (see Changes to the Privacy Policy).
Your continued use of this Website after we make changes is deemed to be acceptance of those
changes, so please check the policy periodically for updates.
</p>
<h2>Introduction</h2>
<p>
<a href="https://modrinth.com">Modrinth</a> is part of Rinth, Inc. ("Company", "us", "we",
"our"). This privacy policy explains how we collect data, process it, and your rights relative
to your data.
</p>
<p>
This policy describes the types of information we may collect from you or that you may provide
when you use www.modrinth.com, api.modrinth.com, or the Modrinth App ("Service" or "Website"),
and our practices for collecting, using, maintaining, protecting, and disclosing that
information.
</p>
<p>This policy applies to information we collect:</p>
<ul>
<li>On this Website.</li>
<li>In email, text, and other electronic messages between you and this Website.</li>
<li>
Through mobile and desktop applications you download from this Website, which provide
dedicated non-browser-based interaction between you and this Website.
</li>
<li>
When you interact with our advertising and applications on third-party websites and
services, if those applications or advertising include links to this policy.
</li>
</ul>
<p>It does not apply to information collected by:</p>
<ul>
<li>
Us offline or through any other means, including on any other website operated by Rinth,
Inc. or any third party (including our affiliates and subsidiaries); or
</li>
<li>
Any third party (including our affiliates and subsidiaries), including through any
application or content (including advertising) that may link to or be accessible from or on
the Website
</li>
</ul>
<p>
Please read this policy carefully to understand our policies and practices regarding your
information and how we will treat it. If you do not agree with our policies and practices,
your choice is not to use our Website. By accessing or using this Website, you agree to this
privacy policy. This policy may change from time to time (see Changes to the Privacy Policy).
Your continued use of this Website after we make changes is deemed to be acceptance of those
changes, so please check the policy periodically for updates.
</p>
<h2>Foreword</h2>
<h2>Foreword</h2>
<p>
The following document was created as required by several laws, including but not limited to:
</p>
<ul>
<li>
the California Consumer Privacy Act (CA CCPA), more information about which can be found on
<a href="https://oag.ca.gov/privacy/ccpa">oag.ca.gov</a>
</li>
<li>
the European Union General Data Protection Regulation (EU GDPR), more information about
which can be found on
<a href="https://gdpr.eu/">gdpr.eu</a>
</li>
</ul>
<p>
The following document was created as required by several laws, including but not limited to:
</p>
<ul>
<li>
the California Consumer Privacy Act (CA CCPA), more information about which can be found on
<a href="https://oag.ca.gov/privacy/ccpa">oag.ca.gov</a>
</li>
<li>
the European Union General Data Protection Regulation (EU GDPR), more information about
which can be found on
<a href="https://gdpr.eu/">gdpr.eu</a>
</li>
</ul>
<p>Rinth, Inc. is the data controller for data collected through Modrinth.</p>
<p>Rinth, Inc. is the data controller for data collected through Modrinth.</p>
<h2>What data do we collect?</h2>
<h2>What data do we collect?</h2>
<h3>User data</h3>
<p>When you create an account, we collect:</p>
<ul>
<li>Your email</li>
<li>Your username</li>
<li>Your display name</li>
<li>Your profile picture</li>
<li>Your OAuth application data (ex: GitHub or Discord ID)</li>
</ul>
<p>
This data is used to identify you and display your profile. It will be linked to your
projects.
</p>
<h3>User data</h3>
<p>When you create an account, we collect:</p>
<ul>
<li>Your email</li>
<li>Your username</li>
<li>Your display name</li>
<li>Your profile picture</li>
<li>Your OAuth application data (ex: GitHub or Discord ID)</li>
</ul>
<p>
This data is used to identify you and display your profile. It will be linked to your
projects.
</p>
<h3>View data and download data</h3>
<p>When you view a project page or download a file from Modrinth, we collect:</p>
<ul>
<li>Your IP address</li>
<li>Your user ID (if applicable)</li>
<li>The project viewed and/or the file downloaded</li>
<li>Your country</li>
<li>Some additional metadata about your connection (HTTP headers)</li>
</ul>
<p>This data is used to monitor automated access to our service and deliver statistics.</p>
<h3>View data and download data</h3>
<p>When you view a project page or download a file from Modrinth, we collect:</p>
<ul>
<li>Your IP address</li>
<li>Your user ID (if applicable)</li>
<li>The project viewed and/or the file downloaded</li>
<li>Your country</li>
<li>Some additional metadata about your connection (HTTP headers)</li>
</ul>
<p>This data is used to monitor automated access to our service and deliver statistics.</p>
<h3>Playtime data</h3>
<p>When you use the Modrinth App to play Modrinth projects, we collect:</p>
<ul>
<li>Your IP address</li>
<li>Your user ID</li>
<li>The amount of time the project was played for</li>
<li>The project played</li>
<li>
Some additional metadata about the projects you're playing (loaders and game versions)
</li>
</ul>
<p>This data is used to deliver statistics.</p>
<h3>Playtime data</h3>
<p>When you use the Modrinth App to play Modrinth projects, we collect:</p>
<ul>
<li>Your IP address</li>
<li>Your user ID</li>
<li>The amount of time the project was played for</li>
<li>The project played</li>
<li>
Some additional metadata about the projects you're playing (loaders and game versions)
</li>
</ul>
<p>This data is used to deliver statistics.</p>
<h3>Usage data</h3>
<p>When you interact with the Modrinth App or the Website, we collect through PostHog:</p>
<ul>
<li>Your IP address</li>
<li>Your anonymized user ID</li>
<li>The time the interaction happened</li>
<li>Some additional metadata about the device you are on</li>
<li>Some additional metadata about each interaction</li>
</ul>
<p>This data is used to deliver statistics.</p>
<h3>Usage data</h3>
<p>When you interact with the Modrinth App or the Website, we collect through PostHog:</p>
<ul>
<li>Your IP address</li>
<li>Your anonymized user ID</li>
<li>The time the interaction happened</li>
<li>Some additional metadata about the device you are on</li>
<li>Some additional metadata about each interaction</li>
</ul>
<p>This data is used to deliver statistics.</p>
<h3>Creator Monetization Program data</h3>
<p>
When you sign up for our
<nuxt-link to="/news/article/creator-monetization-beta">
Creator Monetization Program</nuxt-link
>
(the "CMP"), we collect:
</p>
<ul>
<li>Your PayPal email address (if applicable)</li>
<li>Your Venmo username (if applicable)</li>
</ul>
<p>This data is used to carry out the CMP. It will be linked to your transactions.</p>
<h3>Creator Monetization Program data</h3>
<p>
When you sign up for our
<nuxt-link to="/news/article/creator-monetization-beta">
Creator Monetization Program</nuxt-link
>
(the "CMP"), we collect:
</p>
<ul>
<li>Your PayPal email address (if applicable)</li>
<li>Your Venmo username (if applicable)</li>
</ul>
<p>This data is used to carry out the CMP. It will be linked to your transactions.</p>
<h2>Data retention</h2>
<p>
View data and download data are anonymized 24 months after being recorded. All personal
information will be removed from those records during anonymization.<br />
Data is retained indefinitely. We do not delete any data unless you request it.
</p>
<h2>Data retention</h2>
<p>
View data and download data are anonymized 24 months after being recorded. All personal
information will be removed from those records during anonymization.<br />
Data is retained indefinitely. We do not delete any data unless you request it.
</p>
<h2>Third-party services</h2>
<p>
We use some third-party services to make Modrinth run. Please refer to each of their privacy
policies for more information:
</p>
<ul>
<li>
<a href="https://www.cloudflare.com/en-gb/gdpr/introduction/"> Cloudflare </a>
</li>
<li><a href="https://sentry.io/trust/privacy/">Sentry</a></li>
<li><a href="https://posthog.com/privacy">PostHog</a></li>
<li><a href="https://www.beehiiv.com/privacy">BeeHiiv</a></li>
<li><a href="https://www.paypal.com/us/legalhub/privacy-full">PayPal</a></li>
<li><a href="https://stripe.com/privacy">Stripe</a></li>
</ul>
<p>
Data that we specifically collect isn't shared with any other third party. We do not sell any
data.
</p>
<h2>Third-party services</h2>
<p>
We use some third-party services to make Modrinth run. Please refer to each of their privacy
policies for more information:
</p>
<ul>
<li>
<a href="https://www.cloudflare.com/en-gb/gdpr/introduction/"> Cloudflare </a>
</li>
<li><a href="https://sentry.io/trust/privacy/">Sentry</a></li>
<li><a href="https://posthog.com/privacy">PostHog</a></li>
<li><a href="https://www.beehiiv.com/privacy">BeeHiiv</a></li>
<li><a href="https://www.paypal.com/us/legalhub/privacy-full">PayPal</a></li>
<li><a href="https://stripe.com/privacy">Stripe</a></li>
</ul>
<p>
Data that we specifically collect isn't shared with any other third party. We do not sell any
data.
</p>
<h2>Data Governance</h2>
<p>
Database access is limited to the minimum amount of Rinth, Inc. employees required to run the
service.<br />
Data is stored in a jurisdiction that is part of the European Economic Area (EEA), encrypted
both in storage and in transit.
</p>
<h2>Data Governance</h2>
<p>
Database access is limited to the minimum amount of Rinth, Inc. employees required to run the
service.<br />
Data is stored in a jurisdiction that is part of the European Economic Area (EEA), encrypted
both in storage and in transit.
</p>
<h2>Marketing and advertising</h2>
<p>
We use anonymized statistics to conduct marketing and advertising through
<a href="https://adrinth.com/">Adrinth</a>.
</p>
<h2>Marketing and advertising</h2>
<p>
We use anonymized statistics to conduct marketing and advertising through
<a href="https://adrinth.com/">Adrinth</a>.
</p>
<h2>Cookies</h2>
<p>We use cookies to log you into your account and save your cosmetic preferences.</p>
<p>
Cookies are text files placed on your computer to collect standard Internet information. For
more information, please visit
<a href="https://allaboutcookies.org/">allaboutcookies.org</a>.
</p>
<p>
You can set your browser not to accept cookies, and the above website tells you how to remove
cookies from your browser. However, in a few cases, some of our website features may not
function as a result.
</p>
<h2>Cookies</h2>
<p>We use cookies to log you into your account and save your cosmetic preferences.</p>
<p>
Cookies are text files placed on your computer to collect standard Internet information. For
more information, please visit
<a href="https://allaboutcookies.org/">allaboutcookies.org</a>.
</p>
<p>
You can set your browser not to accept cookies, and the above website tells you how to remove
cookies from your browser. However, in a few cases, some of our website features may not
function as a result.
</p>
<h2>Access, rectification, erasure, restriction, portability, and objection</h2>
<p>Every user is entitled to the following:</p>
<ul>
<li>
<strong>The right to access</strong> You have the right to request copies of your personal
data. We may charge you a small fee for this service.
</li>
<li>
<strong>The right to rectification</strong> You have the right to request that we correct
any information you believe is inaccurate. You also have the right to request us to complete
the information you believe is incomplete.
</li>
<li>
<strong>The right to erasure</strong> You have the right to request that we erase your
personal data, under certain conditions.
</li>
<li>
<strong>The right to restrict processing</strong> You have the right to request that we
restrict the processing of your personal data, under certain conditions.
</li>
<li>
<strong>The right to data portability</strong> You have the right to request that we
transfer the data that we have collected to another organization, or directly to you, under
certain conditions.
</li>
<li>
<strong>The right to object to processing</strong> You have the right to object to our
processing of your personal data, under certain conditions.
</li>
</ul>
<p>
If you would like to exercise those rights, contact us at
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a>. We may ask you to verify your
identity before proceeding and will respond to your request within 30 days as required by law,
or notify you of an extended reply time.
</p>
<h2>Access, rectification, erasure, restriction, portability, and objection</h2>
<p>Every user is entitled to the following:</p>
<ul>
<li>
<strong>The right to access</strong> You have the right to request copies of your personal
data. We may charge you a small fee for this service.
</li>
<li>
<strong>The right to rectification</strong> You have the right to request that we correct
any information you believe is inaccurate. You also have the right to request us to complete
the information you believe is incomplete.
</li>
<li>
<strong>The right to erasure</strong> You have the right to request that we erase your
personal data, under certain conditions.
</li>
<li>
<strong>The right to restrict processing</strong> You have the right to request that we
restrict the processing of your personal data, under certain conditions.
</li>
<li>
<strong>The right to data portability</strong> You have the right to request that we
transfer the data that we have collected to another organization, or directly to you, under
certain conditions.
</li>
<li>
<strong>The right to object to processing</strong> You have the right to object to our
processing of your personal data, under certain conditions.
</li>
</ul>
<p>
If you would like to exercise those rights, contact us at
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a>. We may ask you to verify your
identity before proceeding and will respond to your request within 30 days as required by law,
or notify you of an extended reply time.
</p>
<h2>Children's Information</h2>
<p>
Another part of our priority is adding protection for children while using the Internet. We
encourage parents and guardians to observe, participate in, and/or monitor and guide their
online activity.
</p>
<p>
Modrinth does not knowingly collect any Personal Identifiable Information from children under
the age of 13. If you think that your child provided this kind of information on our website,
we strongly encourage you to contact us immediately and we will do our best efforts to
promptly remove such information from our records.
</p>
<h2>Children's Information</h2>
<p>
Another part of our priority is adding protection for children while using the Internet. We
encourage parents and guardians to observe, participate in, and/or monitor and guide their
online activity.
</p>
<p>
Modrinth does not knowingly collect any Personal Identifiable Information from children under
the age of 13. If you think that your child provided this kind of information on our website,
we strongly encourage you to contact us immediately and we will do our best efforts to
promptly remove such information from our records.
</p>
<h2>Online Privacy Policy Only</h2>
<p>
This Privacy Policy applies only to our online activities and is valid for visitors to our
website with regards to the information that they shared and/or collect in Modrinth. This
policy is not applicable to any information collected offline or via channels other than this
website.
</p>
<h2>Online Privacy Policy Only</h2>
<p>
This Privacy Policy applies only to our online activities and is valid for visitors to our
website with regards to the information that they shared and/or collect in Modrinth. This
policy is not applicable to any information collected offline or via channels other than this
website.
</p>
<h2>Consent</h2>
<p>
By using our website, you hereby consent to our Privacy Policy and agree to its Terms and
Conditions.
</p>
<h2>Consent</h2>
<p>
By using our website, you hereby consent to our Privacy Policy and agree to its Terms and
Conditions.
</p>
<h2>California Privacy Rights</h2>
<p>
If you are a California resident, California law may provide you with additional rights
regarding our use of your personal information. To learn more about your California privacy
rights, visit <nuxt-link to="/legal/ccpa">this page</nuxt-link>.
</p>
<p>
California's "Shine the Light" law (Civil Code Section § 1798.83) permits users of our App
that are California residents to request certain information regarding our disclosure of
personal information to third parties for their direct marketing purposes. To make such a
request, please send an email to
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
<h2>California Privacy Rights</h2>
<p>
If you are a California resident, California law may provide you with additional rights
regarding our use of your personal information. To learn more about your California privacy
rights, visit <nuxt-link to="/legal/ccpa">this page</nuxt-link>.
</p>
<p>
California's "Shine the Light" law (Civil Code Section § 1798.83) permits users of our App
that are California residents to request certain information regarding our disclosure of
personal information to third parties for their direct marketing purposes. To make such a
request, please send an email to
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
<h2>Changes to the Privacy Policy</h2>
<p>
We keep this privacy policy under regular review and place any updates on this web page. If we
do this, we will post the changes on this page and update the "Last edited" date at the top of
this page, after which such changes will become effective immediately. We will make an effort
to keep users updated on any such changes, but because most changes do not affect how we
process existing data, a notice will not be sent for all changes.
</p>
<h2>Changes to the Privacy Policy</h2>
<p>
We keep this privacy policy under regular review and place any updates on this web page. If we
do this, we will post the changes on this page and update the "Last edited" date at the top of
this page, after which such changes will become effective immediately. We will make an effort
to keep users updated on any such changes, but because most changes do not affect how we
process existing data, a notice will not be sent for all changes.
</p>
<h2>Contact</h2>
<p>
If you have any questions about this privacy policy or how we process your data, contact us at
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a> or write us at:
</p>
<p>
Rinth, Inc.<br />
410 North Scottsdale Road<br />
Suite 1000<br />
Tempe, AZ 85281
</p>
<h2>Contact</h2>
<p>
If you have any questions about this privacy policy or how we process your data, contact us at
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a> or write us at:
</p>
<p>
Rinth, Inc.<br />
410 North Scottsdale Road<br />
Suite 1000<br />
Tempe, AZ 85281
</p>
<h3>How to contact the appropriate authority</h3>
<p>
Should you wish to fill a complaint or if you feel like we haven't addressed your concerns or
request, you may contact the
<a href="https://ico.org.uk/">Information Commissioner's Office</a>
using their online form or by writing at:
</p>
<p>
Information Commissioner's Office<br />
Wycliffe House<br />
Water Lane<br />
Wilmslow<br />
Cheshire<br />
SK9 5AF<br />
United Kingdom
</p>
<p>
You do not need to be a citizen of the United Kingdom to use this method of lodging
complaints.
</p>
</div>
<h3>How to contact the appropriate authority</h3>
<p>
Should you wish to fill a complaint or if you feel like we haven't addressed your concerns or
request, you may contact the
<a href="https://ico.org.uk/">Information Commissioner's Office</a>
using their online form or by writing at:
</p>
<p>
Information Commissioner's Office<br />
Wycliffe House<br />
Water Lane<br />
Wilmslow<br />
Cheshire<br />
SK9 5AF<br />
United Kingdom
</p>
<p>
You do not need to be a citizen of the United Kingdom to use this method of lodging
complaints.
</p>
</div>
</template>
<script setup>
const description =
"The Privacy Policy of Modrinth, an open source modding platform focused on Minecraft.";
'The Privacy Policy of Modrinth, an open source modding platform focused on Minecraft.'
useSeoMeta({
title: "Privacy Policy - Modrinth",
description,
ogTitle: "Privacy Policy",
ogDescription: description,
});
title: 'Privacy Policy - Modrinth',
description,
ogTitle: 'Privacy Policy',
ogDescription: description,
})
</script>

View File

@@ -1,188 +1,188 @@
<template>
<div class="markdown-body">
<h1>Content Rules</h1>
<div class="markdown-body">
<h1>Content Rules</h1>
<p>
These Content Rules are to be considered part of our
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link> and apply to any and all User
Contributions, Gaming Content, and use of Interactive Services (collectively, "Content").
</p>
<p>
These Content Rules are to be considered part of our
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link> and apply to any and all User
Contributions, Gaming Content, and use of Interactive Services (collectively, "Content").
</p>
<p>
If you find any violations of these Rules on our website, you should make us aware. You may
use the Report button on any project, version, or user page, or you may email us at
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
<p>
If you find any violations of these Rules on our website, you should make us aware. You may
use the Report button on any project, version, or user page, or you may email us at
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
<h2 id="prohibited-content">1. Prohibited Content</h2>
<h2 id="prohibited-content">1. Prohibited Content</h2>
<p>
Content must in their entirety comply with all applicable federal, state, local, and
international laws and regulations. Without limiting the foregoing, Content must not:
</p>
<ol>
<li>
Contain any material which is defamatory, obscene, indecent, abusive, offensive, harassing,
violent, hateful, inflammatory, harmful, damaging, disruptive, contradictory, or otherwise
objectionable.
</li>
<li>
Promote sexually explicit or pornographic material, violence, or discrimination based on
race, sex, gender, religion, nationality, disability, sexual orientation, or age.
</li>
<li>
Infringe any patent, trademark, trade secret, copyright, or other intellectual property or
other rights of any other person.
</li>
<li>
Violate the legal rights (including the rights of publicity and privacy) of others or
contain any material that could give rise to any civil or criminal liability under
applicable laws or regulations or that otherwise may be in conflict with our
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link> or
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>.
</li>
<li>
Promote any illegal activity, or advocate, promote or assist any unlawful act, including
real-life drugs or illicit substances.
</li>
<li>
Cause annoyance, inconvenience, or needless anxiety or be likely to upset, embarrass, alarm,
annoy, harm, or deceive any other person.
</li>
<li>Make or share intentionally wrong or misleading claims.</li>
<li>
Impersonate any person, or misrepresent your identity or affiliation with any person or
organization.
</li>
<li>
Give the impression that they emanate from or are endorsed by us or any other person or
entity, if this is not the case.
</li>
<li>Contain an excessive amount of profane language.</li>
<li>
Be designed to upload any data to a remote server (i.e. one that the user does not directly
choose to connect to in-game) without clear disclosure.
</li>
<li>
Bypass restrictions placed by Mojang to prevent users from joining certain in-game servers.
</li>
</ol>
<p>
Content must in their entirety comply with all applicable federal, state, local, and
international laws and regulations. Without limiting the foregoing, Content must not:
</p>
<ol>
<li>
Contain any material which is defamatory, obscene, indecent, abusive, offensive, harassing,
violent, hateful, inflammatory, harmful, damaging, disruptive, contradictory, or otherwise
objectionable.
</li>
<li>
Promote sexually explicit or pornographic material, violence, or discrimination based on
race, sex, gender, religion, nationality, disability, sexual orientation, or age.
</li>
<li>
Infringe any patent, trademark, trade secret, copyright, or other intellectual property or
other rights of any other person.
</li>
<li>
Violate the legal rights (including the rights of publicity and privacy) of others or
contain any material that could give rise to any civil or criminal liability under
applicable laws or regulations or that otherwise may be in conflict with our
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link> or
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>.
</li>
<li>
Promote any illegal activity, or advocate, promote or assist any unlawful act, including
real-life drugs or illicit substances.
</li>
<li>
Cause annoyance, inconvenience, or needless anxiety or be likely to upset, embarrass, alarm,
annoy, harm, or deceive any other person.
</li>
<li>Make or share intentionally wrong or misleading claims.</li>
<li>
Impersonate any person, or misrepresent your identity or affiliation with any person or
organization.
</li>
<li>
Give the impression that they emanate from or are endorsed by us or any other person or
entity, if this is not the case.
</li>
<li>Contain an excessive amount of profane language.</li>
<li>
Be designed to upload any data to a remote server (i.e. one that the user does not directly
choose to connect to in-game) without clear disclosure.
</li>
<li>
Bypass restrictions placed by Mojang to prevent users from joining certain in-game servers.
</li>
</ol>
<h2 id="clear-and-honest-function">2. Clear and Honest Function</h2>
<h2 id="clear-and-honest-function">2. Clear and Honest Function</h2>
<p>
Projects, a form of Content, must make a clear and honest attempt to describe their purpose in
designated areas on the project page. Necessary information must not be obscured in any way.
Using confusing language or technical jargon when it is not necessary constitutes a violation.
</p>
<p>
Projects, a form of Content, must make a clear and honest attempt to describe their purpose in
designated areas on the project page. Necessary information must not be obscured in any way.
Using confusing language or technical jargon when it is not necessary constitutes a violation.
</p>
<h3 id="general-expectations">2.1. General Expectations</h3>
<h3 id="general-expectations">2.1. General Expectations</h3>
<p>
From a project description, users should be able to understand what the project does and how
to use it. Projects must attempt to describe the following three things within their
description:
</p>
<ol type="a">
<li>what the project specifically does or adds</li>
<li>why someone should want to download the project</li>
<li>any other critical information the user must know before downloading</li>
</ol>
<p>
From a project description, users should be able to understand what the project does and how
to use it. Projects must attempt to describe the following three things within their
description:
</p>
<ol type="a">
<li>what the project specifically does or adds</li>
<li>why someone should want to download the project</li>
<li>any other critical information the user must know before downloading</li>
</ol>
<h3 id="accessibility">2.2. Accessibility</h3>
<h3 id="accessibility">2.2. Accessibility</h3>
<p>
Project descriptions must be accessible so that they can be read through a variety of mediums.
All descriptions must have a plain-text version, though images, videos, and other content can
take priority if desired. Headers must not be used for body text.
</p>
<p>
Project descriptions must be accessible so that they can be read through a variety of mediums.
All descriptions must have a plain-text version, though images, videos, and other content can
take priority if desired. Headers must not be used for body text.
</p>
<p>
Project descriptions must have an English-language translation unless they are exclusively
meant for use in a specific language, such as translation packs. Descriptions may provide
translations into other languages if desired.
</p>
<p>
Project descriptions must have an English-language translation unless they are exclusively
meant for use in a specific language, such as translation packs. Descriptions may provide
translations into other languages if desired.
</p>
<h2 id="cheats-and-hacks">3. Cheats and Hacks</h2>
<h2 id="cheats-and-hacks">3. Cheats and Hacks</h2>
<p>
Projects cannot contain or download "cheats", which we define as a client-side modification
that:
</p>
<ol>
<li>is advertised as a "cheat", "hack", or "hacked client"</li>
<li>
gives an unfair advantage in a multiplayer setting over other players that do not have a
comparable modification and does not provide a server-side opt-out
</li>
<li>
contains any of the following functions without requiring a server-side opt-in:
<ol type="a">
<li>X-ray or the ability to see through opaque blocks</li>
<li>aim bot or aim assist</li>
<li>flight, speed, or other movement modifications</li>
<li>automatic or assisted PvP combat</li>
<li>
active client-side hiding of third party modifications that have server-side opt-outs
</li>
<li>item duplication</li>
</ol>
</li>
</ol>
<p>
Projects cannot contain or download "cheats", which we define as a client-side modification
that:
</p>
<ol>
<li>is advertised as a "cheat", "hack", or "hacked client"</li>
<li>
gives an unfair advantage in a multiplayer setting over other players that do not have a
comparable modification and does not provide a server-side opt-out
</li>
<li>
contains any of the following functions without requiring a server-side opt-in:
<ol type="a">
<li>X-ray or the ability to see through opaque blocks</li>
<li>aim bot or aim assist</li>
<li>flight, speed, or other movement modifications</li>
<li>automatic or assisted PvP combat</li>
<li>
active client-side hiding of third party modifications that have server-side opt-outs
</li>
<li>item duplication</li>
</ol>
</li>
</ol>
<h2 id="copyright-and-legality-of-content">4. Copyright and Reuploads</h2>
<h2 id="copyright-and-legality-of-content">4. Copyright and Reuploads</h2>
<p>
You must own or have the necessary licenses, rights, consents, and permissions to store,
share, and distribute the Content that is uploaded under your Modrinth account.
</p>
<p>
You must own or have the necessary licenses, rights, consents, and permissions to store,
share, and distribute the Content that is uploaded under your Modrinth account.
</p>
<p>
Content may not be directly reuploaded from another source without explicit permission from
the original author. If explicit permission has been granted, or it is a license-abiding
"fork", this restriction does not apply. We define "forks" as modified copies of a project
which have diverged substantially from the original project.
</p>
<p>
Content may not be directly reuploaded from another source without explicit permission from
the original author. If explicit permission has been granted, or it is a license-abiding
"fork", this restriction does not apply. We define "forks" as modified copies of a project
which have diverged substantially from the original project.
</p>
<h2 id="miscellaneous">5. Miscellaneous</h2>
<h2 id="miscellaneous">5. Miscellaneous</h2>
<p>
There are certain other small aspects to creating projects that all authors should attempt to
abide by. These will not necessarily always be enforced, but abiding by all will result in a
faster review with fewer potential issues.
</p>
<ol>
<li>
All metadata, including license, client/server-side information, tags, etc. are filled out
correctly and are consistent with information found elsewhere.
</li>
<li>
Project titles are only the name of the project, without any other unnecessary filler data.
</li>
<li>
Project summaries contain a small summary of the project without any formatting and without
repeating the project title.
</li>
<li>All external links lead to public resources that are relevant.</li>
<li>Gallery images are relevant to the project and each contain a title.</li>
<li>All dependencies must be specified in the Dependencies section of each version.</li>
<li>
"Additional files" are only used for special designated purposes, such as source JAR files.
In other words, separate versions and/or projects are used where appropriate instead of
additional files.
</li>
</ol>
</div>
<p>
There are certain other small aspects to creating projects that all authors should attempt to
abide by. These will not necessarily always be enforced, but abiding by all will result in a
faster review with fewer potential issues.
</p>
<ol>
<li>
All metadata, including license, client/server-side information, tags, etc. are filled out
correctly and are consistent with information found elsewhere.
</li>
<li>
Project titles are only the name of the project, without any other unnecessary filler data.
</li>
<li>
Project summaries contain a small summary of the project without any formatting and without
repeating the project title.
</li>
<li>All external links lead to public resources that are relevant.</li>
<li>Gallery images are relevant to the project and each contain a title.</li>
<li>All dependencies must be specified in the Dependencies section of each version.</li>
<li>
"Additional files" are only used for special designated purposes, such as source JAR files.
In other words, separate versions and/or projects are used where appropriate instead of
additional files.
</li>
</ol>
</div>
</template>
<script setup>
const description =
"The Content Rules of Modrinth, an open source modding platform focused on Minecraft.";
'The Content Rules of Modrinth, an open source modding platform focused on Minecraft.'
useSeoMeta({
title: "Content Rules - Modrinth",
description,
ogTitle: "Content Rules",
ogDescription: description,
});
title: 'Content Rules - Modrinth',
description,
ogTitle: 'Content Rules',
ogDescription: description,
})
</script>

View File

@@ -1,65 +1,65 @@
<template>
<div class="markdown-body">
<h1>Security Notice</h1>
<div class="markdown-body">
<h1>Security Notice</h1>
<p>
This is the security notice for all Modrinth repositories. The notice explains how
vulnerabilities should be reported.
</p>
<h2>Reporting a Vulnerability</h2>
<p>
If you've found a vulnerability, we would like to know so we can fix it before it is released
publicly.
<strong>Do not open a GitHub issue for a found vulnerability</strong>.
</p>
<p>
Send details to <a href="mailto:jai@modrinth.com">jai@modrinth.com</a>
including:
</p>
<ul>
<li>the website, page or repository where the vulnerability can be observed</li>
<li>a brief description of the vulnerability</li>
<li>
optionally the type of vulnerability and any related
<a href="https://www.owasp.org/index.php/Category:OWASP_Top_Ten_2017_Project">
OWASP category
</a>
</li>
<li>non-destructive exploitation details</li>
</ul>
<p>We will do our best to reply as fast as possible.</p>
<h2>Scope</h2>
<p>The following vulnerabilities <strong>are not</strong> in scope:</p>
<ul>
<li>
volumetric vulnerabilities, for example overwhelming a service with a high volume of
requests
</li>
<li>
reports indicating that our services do not fully align with "best practice", for example
missing security headers
</li>
</ul>
<p>If you aren't sure, you can still reach out via email or direct message.</p>
<hr />
<p>
This notice is inspired by the
<a href="https://www.pythondiscord.com/pages/security-notice/">
Python Discord Security Notice</a
>.
</p>
<p><em>Version 2022-11</em></p>
</div>
<p>
This is the security notice for all Modrinth repositories. The notice explains how
vulnerabilities should be reported.
</p>
<h2>Reporting a Vulnerability</h2>
<p>
If you've found a vulnerability, we would like to know so we can fix it before it is released
publicly.
<strong>Do not open a GitHub issue for a found vulnerability</strong>.
</p>
<p>
Send details to <a href="mailto:jai@modrinth.com">jai@modrinth.com</a>
including:
</p>
<ul>
<li>the website, page or repository where the vulnerability can be observed</li>
<li>a brief description of the vulnerability</li>
<li>
optionally the type of vulnerability and any related
<a href="https://www.owasp.org/index.php/Category:OWASP_Top_Ten_2017_Project">
OWASP category
</a>
</li>
<li>non-destructive exploitation details</li>
</ul>
<p>We will do our best to reply as fast as possible.</p>
<h2>Scope</h2>
<p>The following vulnerabilities <strong>are not</strong> in scope:</p>
<ul>
<li>
volumetric vulnerabilities, for example overwhelming a service with a high volume of
requests
</li>
<li>
reports indicating that our services do not fully align with "best practice", for example
missing security headers
</li>
</ul>
<p>If you aren't sure, you can still reach out via email or direct message.</p>
<hr />
<p>
This notice is inspired by the
<a href="https://www.pythondiscord.com/pages/security-notice/">
Python Discord Security Notice</a
>.
</p>
<p><em>Version 2022-11</em></p>
</div>
</template>
<script setup>
const description =
"The Security Notice of Modrinth, an open source modding platform focused on Minecraft.";
'The Security Notice of Modrinth, an open source modding platform focused on Minecraft.'
useSeoMeta({
title: "Security Notice - Modrinth",
description,
ogTitle: "Security Notice",
ogDescription: description,
});
title: 'Security Notice - Modrinth',
description,
ogTitle: 'Security Notice',
ogDescription: description,
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +1,85 @@
<template>
<div
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<h1>Moderation</h1>
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
<div class="mb-4 sm:hidden">
<Chips
v-model="selectedChip"
:items="mobileNavOptions"
:never-empty="true"
@change="navigateToPage"
/>
</div>
<NuxtPage />
</div>
<div
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<h1>Moderation</h1>
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
<div class="mb-4 sm:hidden">
<Chips
v-model="selectedChip"
:items="mobileNavOptions"
:never-empty="true"
@change="navigateToPage"
/>
</div>
<NuxtPage />
</div>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from "@vintl/vintl";
import { Chips } from "@modrinth/ui";
import NavTabs from "@/components/ui/NavTabs.vue";
import { Chips } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import NavTabs from '@/components/ui/NavTabs.vue'
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const messages = defineMessages({
projectsTitle: {
id: "moderation.page.projects",
defaultMessage: "Projects",
},
technicalReviewTitle: {
id: "moderation.page.technicalReview",
defaultMessage: "Technical Review",
},
reportsTitle: {
id: "moderation.page.reports",
defaultMessage: "Reports",
},
});
projectsTitle: {
id: 'moderation.page.projects',
defaultMessage: 'Projects',
},
technicalReviewTitle: {
id: 'moderation.page.technicalReview',
defaultMessage: 'Technical Review',
},
reportsTitle: {
id: 'moderation.page.reports',
defaultMessage: 'Reports',
},
})
const moderationLinks = [
{ label: formatMessage(messages.projectsTitle), href: "/moderation" },
{ label: formatMessage(messages.technicalReviewTitle), href: "/moderation/technical-review" },
{ label: formatMessage(messages.reportsTitle), href: "/moderation/reports" },
];
{ label: formatMessage(messages.projectsTitle), href: '/moderation' },
{ label: formatMessage(messages.technicalReviewTitle), href: '/moderation/technical-review' },
{ label: formatMessage(messages.reportsTitle), href: '/moderation/reports' },
]
const mobileNavOptions = [
formatMessage(messages.projectsTitle),
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
];
formatMessage(messages.projectsTitle),
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
]
const selectedChip = computed({
get() {
const path = route.path;
if (path === "/moderation/technical-review") {
return formatMessage(messages.technicalReviewTitle);
} else if (path.startsWith("/moderation/reports/")) {
return formatMessage(messages.reportsTitle);
} else {
return formatMessage(messages.projectsTitle);
}
},
set(value: string) {
navigateToPage(value);
},
});
get() {
const path = route.path
if (path === '/moderation/technical-review') {
return formatMessage(messages.technicalReviewTitle)
} else if (path.startsWith('/moderation/reports/')) {
return formatMessage(messages.reportsTitle)
} else {
return formatMessage(messages.projectsTitle)
}
},
set(value: string) {
navigateToPage(value)
},
})
function navigateToPage(selectedOption: string) {
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
router.push("/moderation/technical-review");
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
router.push("/moderation/reports");
} else {
router.push("/moderation");
}
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
router.push('/moderation/technical-review')
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
router.push('/moderation/reports')
} else {
router.push('/moderation')
}
}
</script>

View File

@@ -1,340 +1,340 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
</ButtonStyled>
</div>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
</ButtonStyled>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:owner="item.owner"
:org="item.org"
/>
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:owner="item.owner"
:org="item.org"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
import {
XIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
FilterIcon,
ScaleIcon,
} from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import ConfettiExplosion from "vue-confetti-explosion";
import Fuse from "fuse.js";
import ModerationQueueCard from "~/components/ui/moderation/ModerationQueueCard.vue";
import { useModerationStore } from "~/store/moderation.ts";
import { enrichProjectBatch, type ModerationProject } from "~/helpers/moderation.ts";
FilterIcon,
ScaleIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
XIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, DropdownSelect, Pagination } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import Fuse from 'fuse.js'
import ConfettiExplosion from 'vue-confetti-explosion'
const { formatMessage } = useVIntl();
const moderationStore = useModerationStore();
const route = useRoute();
const router = useRouter();
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
import { useModerationStore } from '~/store/moderation.ts'
const visible = ref(false);
const { formatMessage } = useVIntl()
const moderationStore = useModerationStore()
const route = useRoute()
const router = useRouter()
const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) {
setTimeout(async () => {
history.state.confetti = false;
visible.value = true;
await nextTick();
setTimeout(() => {
visible.value = false;
}, 5000);
}, 1000);
setTimeout(async () => {
history.state.confetti = false
visible.value = true
await nextTick()
setTimeout(() => {
visible.value = false
}, 5000)
}, 1000)
}
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
moderate: {
id: "moderation.moderate",
defaultMessage: "Moderate",
},
});
searchPlaceholder: {
id: 'moderation.search.placeholder',
defaultMessage: 'Search...',
},
filterBy: {
id: 'moderation.filter.by',
defaultMessage: 'Filter by',
},
sortBy: {
id: 'moderation.sort.by',
defaultMessage: 'Sort by',
},
moderate: {
id: 'moderation.moderate',
defaultMessage: 'Moderate',
},
})
const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
const startTime = performance.now();
let currentOffset = 0;
const PROJECT_ENDPOINT_COUNT = 350;
const allProjects: ModerationProject[] = [];
const { data: allProjects } = await useLazyAsyncData('moderation-projects', async () => {
const startTime = performance.now()
let currentOffset = 0
const PROJECT_ENDPOINT_COUNT = 350
const allProjects: ModerationProject[] = []
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
const enrichmentPromises: Promise<ModerationProject[]>[] = []
while (true) {
const projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[];
let projects: any[] = []
do {
projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[]
if (projects.length === 0) break;
if (projects.length === 0) break
const enrichmentPromise = enrichProjectBatch(projects);
enrichmentPromises.push(enrichmentPromise);
const enrichmentPromise = enrichProjectBatch(projects)
enrichmentPromises.push(enrichmentPromise)
currentOffset += projects.length;
currentOffset += projects.length
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allProjects.push(...completed.flat());
}
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allProjects.push(...completed.flat())
}
} while (projects.length === PROJECT_ENDPOINT_COUNT)
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises)
allProjects.push(...remainingBatches.flat())
const remainingBatches = await Promise.all(enrichmentPromises);
allProjects.push(...remainingBatches.flat());
const endTime = performance.now()
const duration = endTime - startTime
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allProjects
})
return allProjects;
});
const query = ref(route.query.q?.toString() || "");
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
},
)
const currentFilterType = ref("All projects");
const currentFilterType = ref('All projects')
const filterTypes: readonly string[] = readonly([
"All projects",
"Modpacks",
"Mods",
"Resource Packs",
"Data Packs",
"Plugins",
"Shaders",
]);
'All projects',
'Modpacks',
'Mods',
'Resource Packs',
'Data Packs',
'Plugins',
'Shaders',
])
const currentSortType = ref("Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentSortType = ref('Oldest')
const sortTypes: readonly string[] = readonly(['Oldest', 'Newest'])
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage))
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null;
return new Fuse(allProjects.value, {
keys: [
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 2,
},
{
name: "project.description",
weight: 2,
},
{
name: "project.project_type",
weight: 1,
},
"owner.user.username",
"org.name",
"org.slug",
],
includeScore: true,
threshold: 0.4,
});
});
if (!allProjects.value || allProjects.value.length === 0) return null
return new Fuse(allProjects.value, {
keys: [
{
name: 'project.title',
weight: 3,
},
{
name: 'project.slug',
weight: 2,
},
{
name: 'project.description',
weight: 2,
},
{
name: 'project.project_type',
weight: 1,
},
'owner.user.username',
'org.name',
'org.slug',
],
includeScore: true,
threshold: 0.4,
})
})
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
if (!query.value || !fuse.value) return null
return fuse.value.search(query.value).map((result) => result.item)
})
const baseFiltered = computed(() => {
if (!allProjects.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allProjects.value];
});
if (!allProjects.value) return []
return query.value && searchResults.value ? searchResults.value : [...allProjects.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === "All projects") return baseFiltered.value;
if (currentFilterType.value === 'All projects') return baseFiltered.value
const filterMap: Record<string, string> = {
Modpacks: "modpack",
Mods: "mod",
"Resource Packs": "resourcepack",
"Data Packs": "datapack",
Plugins: "plugin",
Shaders: "shader",
};
const filterMap: Record<string, string> = {
Modpacks: 'modpack',
Mods: 'mod',
'Resource Packs': 'resourcepack',
'Data Packs': 'datapack',
Plugins: 'plugin',
Shaders: 'shader',
}
const projectType = filterMap[currentFilterType.value];
if (!projectType) return baseFiltered.value;
const projectType = filterMap[currentFilterType.value]
if (!projectType) return baseFiltered.value
return baseFiltered.value.filter(
(queueItem) =>
queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType,
);
});
return baseFiltered.value.filter(
(queueItem) =>
queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType,
)
})
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value];
const filtered = [...typeFiltered.value]
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateB - dateA;
});
}
if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateB - dateA
})
}
return filtered;
});
return filtered
})
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredProjects.value.slice(start, end);
});
if (!filteredProjects.value) return []
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredProjects.value.slice(start, end)
})
function goToPage(page: number) {
currentPage.value = page;
currentPage.value = page
}
function moderateAllInFilter() {
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
navigateTo({
name: "type-id",
params: {
type: "project",
id: moderationStore.getCurrentProjectId(),
},
state: {
showChecklist: true,
},
});
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id))
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: moderationStore.getCurrentProjectId(),
},
state: {
showChecklist: true,
},
})
}
</script>

View File

@@ -1,28 +1,29 @@
<script setup lang="ts">
import type { Report } from "@modrinth/utils";
import { enrichReportBatch } from "~/helpers/moderation.ts";
import ModerationReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
import type { Report } from '@modrinth/utils'
const { params } = useRoute();
const reportId = params.id as string;
import ModerationReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
import { enrichReportBatch } from '~/helpers/moderation.ts'
const { params } = useRoute()
const reportId = params.id as string
const { data: report } = await useAsyncData(`moderation-report-${reportId}`, async () => {
try {
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report;
const enrichedReport = (await enrichReportBatch([report]))[0];
return enrichedReport;
} catch (error) {
console.error("Error fetching report:", error);
throw createError({
statusCode: 404,
statusMessage: "Report not found",
});
}
});
try {
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report
const enrichedReport = (await enrichReportBatch([report]))[0]
return enrichedReport
} catch (error) {
console.error('Error fetching report:', error)
throw createError({
statusCode: 404,
statusMessage: 'Report not found',
})
}
})
</script>
<template>
<div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" />
</div>
<div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" />
</div>
</template>

View File

@@ -1,289 +1,288 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import type { Report } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { ExtendedReport } from "@modrinth/moderation";
import ReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
import { enrichReportBatch } from "~/helpers/moderation.ts";
import { FilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets'
import type { ExtendedReport } from '@modrinth/moderation'
import { Button, DropdownSelect, Pagination } from '@modrinth/ui'
import type { Report } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import Fuse from 'fuse.js'
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
import { enrichReportBatch } from '~/helpers/moderation.ts'
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
searchPlaceholder: {
id: 'moderation.search.placeholder',
defaultMessage: 'Search...',
},
filterBy: {
id: 'moderation.filter.by',
defaultMessage: 'Filter by',
},
sortBy: {
id: 'moderation.sort.by',
defaultMessage: 'Sort by',
},
})
const { data: allReports } = await useLazyAsyncData("new-moderation-reports", async () => {
const startTime = performance.now();
let currentOffset = 0;
const REPORT_ENDPOINT_COUNT = 350;
const allReports: ExtendedReport[] = [];
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
const startTime = performance.now()
let currentOffset = 0
const REPORT_ENDPOINT_COUNT = 350
const allReports: ExtendedReport[] = []
const enrichmentPromises: Promise<ExtendedReport[]>[] = [];
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
while (true) {
const reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ apiVersion: 3 },
)) as Report[];
let reports: Report[]
do {
reports = (await useBaseFetch(`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`, {
apiVersion: 3,
})) as Report[]
if (reports.length === 0) break;
if (reports.length === 0) break
const enrichmentPromise = enrichReportBatch(reports);
enrichmentPromises.push(enrichmentPromise);
const enrichmentPromise = enrichReportBatch(reports)
enrichmentPromises.push(enrichmentPromise)
currentOffset += reports.length;
currentOffset += reports.length
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allReports.push(...completed.flat());
}
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allReports.push(...completed.flat())
}
} while (reports.length === REPORT_ENDPOINT_COUNT)
if (reports.length < REPORT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises)
allReports.push(...remainingBatches.flat())
const remainingBatches = await Promise.all(enrichmentPromises);
allReports.push(...remainingBatches.flat());
const endTime = performance.now()
const duration = endTime - startTime
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
console.debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allReports
})
return allReports;
});
const query = ref(route.query.q?.toString() || "");
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
},
)
const currentFilterType = ref("All");
const filterTypes: readonly string[] = readonly(["All", "Unread", "Read"]);
const currentFilterType = ref('All')
const filterTypes: readonly string[] = readonly(['All', 'Unread', 'Read'])
const currentSortType = ref("Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentSortType = ref('Oldest')
const sortTypes: readonly string[] = readonly(['Oldest', 'Newest'])
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage))
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "id",
weight: 3,
},
{
name: "body",
weight: 3,
},
{
name: "report_type",
weight: 3,
},
{
name: "item_id",
weight: 2,
},
{
name: "reporter_user.username",
weight: 2,
},
"project.name",
"project.slug",
"user.username",
"version.name",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
if (!allReports.value || allReports.value.length === 0) return null
return new Fuse(allReports.value, {
keys: [
{
name: 'id',
weight: 3,
},
{
name: 'body',
weight: 3,
},
{
name: 'report_type',
weight: 3,
},
{
name: 'item_id',
weight: 2,
},
{
name: 'reporter_user.username',
weight: 2,
},
'project.name',
'project.slug',
'user.username',
'version.name',
'target.name',
'target.slug',
],
includeScore: true,
threshold: 0.4,
})
})
const memberRoleMap = computed(() => {
if (!allReports.value?.length) return new Map();
if (!allReports.value?.length) return new Map()
const map = new Map();
for (const report of allReports.value) {
if (report.thread?.members?.length) {
const roleMap = new Map();
for (const member of report.thread.members) {
roleMap.set(member.id, member.role);
}
map.set(report.id, roleMap);
}
}
return map;
});
const map = new Map()
for (const report of allReports.value) {
if (report.thread?.members?.length) {
const roleMap = new Map()
for (const member of report.thread.members) {
roleMap.set(member.id, member.role)
}
map.set(report.id, roleMap)
}
}
return map
})
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
if (!query.value || !fuse.value) return null
return fuse.value.search(query.value).map((result) => result.item)
})
const baseFiltered = computed(() => {
if (!allReports.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allReports.value];
});
if (!allReports.value) return []
return query.value && searchResults.value ? searchResults.value : [...allReports.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === "All") return baseFiltered.value;
if (currentFilterType.value === 'All') return baseFiltered.value
return baseFiltered.value.filter((report) => {
const messages = report.thread?.messages || [];
return baseFiltered.value.filter((report) => {
const messages = report.thread?.messages || []
if (messages.length === 0) {
return currentFilterType.value === "Unread";
}
if (messages.length === 0) {
return currentFilterType.value === 'Unread'
}
const lastMessage = messages[messages.length - 1];
if (!lastMessage.author_id) return false;
const lastMessage = messages[messages.length - 1]
if (!lastMessage.author_id) return false
const roleMap = memberRoleMap.value.get(report.id);
if (!roleMap) return false;
const roleMap = memberRoleMap.value.get(report.id)
if (!roleMap) return false
const authorRole = roleMap.get(lastMessage.author_id);
const isModeratorMessage = authorRole === "moderator" || authorRole === "admin";
const authorRole = roleMap.get(lastMessage.author_id)
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
return currentFilterType.value === "Read" ? isModeratorMessage : !isModeratorMessage;
});
});
return currentFilterType.value === 'Read' ? isModeratorMessage : !isModeratorMessage
})
})
const filteredReports = computed(() => {
const filtered = [...typeFiltered.value];
const filtered = [...typeFiltered.value]
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
} else {
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
}
if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime())
} else {
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
}
return filtered;
});
return filtered
})
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
if (!filteredReports.value) return []
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredReports.value.slice(start, end)
})
function goToPage(page: number) {
currentPage.value = page;
currentPage.value = page
}
</script>

View File

@@ -1,386 +1,387 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<DelphiReportCard
v-for="report in paginatedReports"
:key="report.version.id"
:report="report"
/>
<div
v-if="!paginatedReports || paginatedReports.length === 0"
class="universal-card h-24 animate-pulse"
></div>
</div>
<div class="mt-4 flex flex-col gap-2">
<DelphiReportCard
v-for="report in paginatedReports"
:key="report.version.id"
:report="report"
/>
<div
v-if="!paginatedReports || paginatedReports.length === 0"
class="universal-card h-24 animate-pulse"
></div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import type { TeamMember, Organization, DelphiReport, Project, Version } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { OwnershipTarget, ExtendedDelphiReport } from "@modrinth/moderation";
import DelphiReportCard from "~/components/ui/moderation/ModerationDelphiReportCard.vue";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
import { FilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets'
import type { ExtendedDelphiReport, OwnershipTarget } from '@modrinth/moderation'
import { Button, DropdownSelect, Pagination } from '@modrinth/ui'
import type { DelphiReport, Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useLocalStorage } from '@vueuse/core'
import Fuse from 'fuse.js'
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
import DelphiReportCard from '~/components/ui/moderation/ModerationDelphiReportCard.vue'
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.technical.search.placeholder",
defaultMessage: "Search tech reviews...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
searchPlaceholder: {
id: 'moderation.technical.search.placeholder',
defaultMessage: 'Search tech reviews...',
},
filterBy: {
id: 'moderation.filter.by',
defaultMessage: 'Filter by',
},
sortBy: {
id: 'moderation.sort.by',
defaultMessage: 'Sort by',
},
})
async function getProjectQuicklyForMock(projectId: string): Promise<Project> {
return (await useBaseFetch(`project/${projectId}`)) as Project;
return (await useBaseFetch(`project/${projectId}`)) as Project
}
async function getVersionQuicklyForMock(versionId: string): Promise<Version> {
return (await useBaseFetch(`version/${versionId}`)) as Version;
return (await useBaseFetch(`version/${versionId}`)) as Version
}
const mockDelphiReports: DelphiReport[] = [
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/ASMEventHandlerHelper.java",
priority_score: 29,
status: "pending",
detected_at: "2025-04-01T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/SomeOtherFile.java",
priority_score: 48,
status: "rejected",
detected_at: "2025-03-02T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/YetAnotherFile.java",
priority_score: 15,
status: "approved",
detected_at: "2025-02-03T12:00:00Z",
} as DelphiReport,
];
{
project: await getProjectQuicklyForMock('7MoE34WK'),
version: await getVersionQuicklyForMock('cTkKLWgA'),
trace_type: 'url_usage',
file_path: 'me/decce/gnetum/ASMEventHandlerHelper.java',
priority_score: 29,
status: 'pending',
detected_at: '2025-04-01T12:00:00Z',
} as DelphiReport,
{
project: await getProjectQuicklyForMock('7MoE34WK'),
version: await getVersionQuicklyForMock('cTkKLWgA'),
trace_type: 'url_usage',
file_path: 'me/decce/gnetum/SomeOtherFile.java',
priority_score: 48,
status: 'rejected',
detected_at: '2025-03-02T12:00:00Z',
} as DelphiReport,
{
project: await getProjectQuicklyForMock('7MoE34WK'),
version: await getVersionQuicklyForMock('cTkKLWgA'),
trace_type: 'url_usage',
file_path: 'me/decce/gnetum/YetAnotherFile.java',
priority_score: 15,
status: 'approved',
detected_at: '2025-02-03T12:00:00Z',
} as DelphiReport,
]
const { data: allReports } = await useAsyncData("moderation-tech-reviews", async () => {
// TODO: replace with actual API call
const delphiReports = mockDelphiReports;
const { data: allReports } = await useAsyncData('moderation-tech-reviews', async () => {
// TODO: replace with actual API call
const delphiReports = mockDelphiReports
if (delphiReports.length === 0) {
return [];
}
if (delphiReports.length === 0) {
return []
}
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))];
const orgIds = [
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
];
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))]
const orgIds = [
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
]
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
]);
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
])
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean);
const orgTeamsData: TeamMember[][] =
orgTeamIds.length > 0
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: [];
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean)
const orgTeamsData: TeamMember[][] =
orgTeamIds.length > 0
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: []
const teamMap = new Map<string, TeamMember[]>();
const orgMap = new Map<string, Organization>();
const teamMap = new Map<string, TeamMember[]>()
const orgMap = new Map<string, Organization>()
teamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
teamsData.forEach((team) => {
let teamId = null
for (const member of team) {
teamId = member.team_id
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team)
break
}
}
})
orgTeamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
orgTeamsData.forEach((team) => {
let teamId = null
for (const member of team) {
teamId = member.team_id
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team)
break
}
}
})
orgsData.forEach((org: Organization) => {
orgMap.set(org.id, org);
});
orgsData.forEach((org: Organization) => {
orgMap.set(org.id, org)
})
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
let target: OwnershipTarget | undefined;
const project = report.project;
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
let target: OwnershipTarget | undefined
const project = report.project
if (project) {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project) {
let owner: TeamMember | null = null
let org: Organization | null = null
if (project.team) {
const teamMembers = teamMap.get(project.team);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.team) {
const teamMembers = teamMap.get(project.team)
if (teamMembers) {
owner = teamMembers.find((member) => member.role === 'Owner') || null
}
}
if (project.organization) {
org = orgMap.get(project.organization) || null;
}
if (project.organization) {
org = orgMap.get(project.organization) || null
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: "organization",
slug: org.slug,
};
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: "user",
slug: owner.user.username,
};
}
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: 'organization',
slug: org.slug,
}
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: 'user',
slug: owner.user.username,
}
}
}
return {
...report,
target,
};
});
return {
...report,
target,
}
})
extendedReports.sort((a, b) => b.priority_score - a.priority_score);
extendedReports.sort((a, b) => b.priority_score - a.priority_score)
return extendedReports;
});
return extendedReports
})
const query = ref(route.query.q?.toString() || "");
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
},
)
const currentFilterType = useLocalStorage("moderation-tech-reviews-filter-type", () => "Pending");
const filterTypes: readonly string[] = readonly(["All", "Pending", "Approved", "Rejected"]);
const currentFilterType = useLocalStorage('moderation-tech-reviews-filter-type', () => 'Pending')
const filterTypes: readonly string[] = readonly(['All', 'Pending', 'Approved', 'Rejected'])
const currentSortType = useLocalStorage("moderation-tech-reviews-sort-type", () => "Priority");
const sortTypes: readonly string[] = readonly(["Priority", "Oldest", "Newest"]);
const currentSortType = useLocalStorage('moderation-tech-reviews-sort-type', () => 'Priority')
const sortTypes: readonly string[] = readonly(['Priority', 'Oldest', 'Newest'])
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage))
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "version.id",
weight: 3,
},
{
name: "version.version_number",
weight: 3,
},
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 3,
},
{
name: "version.files.filename",
weight: 2,
},
{
name: "trace_type",
weight: 2,
},
{
name: "content",
weight: 0.5,
},
"file_path",
"project.id",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
if (!allReports.value || allReports.value.length === 0) return null
return new Fuse(allReports.value, {
keys: [
{
name: 'version.id',
weight: 3,
},
{
name: 'version.version_number',
weight: 3,
},
{
name: 'project.title',
weight: 3,
},
{
name: 'project.slug',
weight: 3,
},
{
name: 'version.files.filename',
weight: 2,
},
{
name: 'trace_type',
weight: 2,
},
{
name: 'content',
weight: 0.5,
},
'file_path',
'project.id',
'target.name',
'target.slug',
],
includeScore: true,
threshold: 0.4,
})
})
const filteredReports = computed(() => {
if (!allReports.value) return [];
if (!allReports.value) return []
let filtered;
let filtered
if (query.value && fuse.value) {
const results = fuse.value.search(query.value);
filtered = results.map((result) => result.item);
} else {
filtered = [...allReports.value];
}
if (query.value && fuse.value) {
const results = fuse.value.search(query.value)
filtered = results.map((result) => result.item)
} else {
filtered = [...allReports.value]
}
if (currentFilterType.value === "Pending") {
filtered = filtered.filter((report) => report.status === "pending");
} else if (currentFilterType.value === "Approved") {
filtered = filtered.filter((report) => report.status === "approved");
} else if (currentFilterType.value === "Rejected") {
filtered = filtered.filter((report) => report.status === "rejected");
}
if (currentFilterType.value === 'Pending') {
filtered = filtered.filter((report) => report.status === 'pending')
} else if (currentFilterType.value === 'Approved') {
filtered = filtered.filter((report) => report.status === 'approved')
} else if (currentFilterType.value === 'Rejected') {
filtered = filtered.filter((report) => report.status === 'rejected')
}
if (currentSortType.value === "Priority") {
filtered.sort((a, b) => b.priority_score - a.priority_score);
} else if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateB - dateA;
});
}
if (currentSortType.value === 'Priority') {
filtered.sort((a, b) => b.priority_score - a.priority_score)
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime()
const dateB = new Date(b.detected_at).getTime()
return dateA - dateB
})
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime()
const dateB = new Date(b.detected_at).getTime()
return dateB - dateA
})
}
return filtered;
});
return filtered
})
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
if (!filteredReports.value) return []
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredReports.value.slice(start, end)
})
function updateSearchResults() {
currentPage.value = 1;
currentPage.value = 1
}
function goToPage(page: number) {
currentPage.value = page;
currentPage.value = page
}
</script>

View File

@@ -1,3 +1,3 @@
<template>
<p>Not yet implemented.</p>
<p>Not yet implemented.</p>
</template>

View File

@@ -1,307 +1,310 @@
<script setup lang="ts">
import { Avatar, ButtonStyled } from "@modrinth/ui";
import { RssIcon, GitGraphIcon } from "@modrinth/assets";
import dayjs from "dayjs";
import { articles as rawArticles } from "@modrinth/blog";
import { computed } from "vue";
import type { User } from "@modrinth/utils";
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
import { GitGraphIcon, RssIcon } from '@modrinth/assets'
import { articles as rawArticles } from '@modrinth/blog'
import { Avatar, ButtonStyled } from '@modrinth/ui'
import type { User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
const config = useRuntimeConfig();
const route = useRoute();
import NewsletterButton from '~/components/ui/NewsletterButton.vue'
import ShareArticleButtons from '~/components/ui/ShareArticleButtons.vue'
const rawArticle = rawArticles.find((article) => article.slug === route.params.slug);
const config = useRuntimeConfig()
const route = useRoute()
const rawArticle = rawArticles.find((article) => article.slug === route.params.slug)
if (!rawArticle) {
throw createError({
fatal: true,
statusCode: 404,
message: "The requested article could not be found.",
});
throw createError({
fatal: true,
statusCode: 404,
message: 'The requested article could not be found.',
})
}
const authorsUrl = `users?ids=${JSON.stringify(rawArticle.authors)}`;
const authorsUrl = `users?ids=${JSON.stringify(rawArticle.authors)}`
const [authors, html] = await Promise.all([
rawArticle.authors
? useAsyncData(authorsUrl, () => useBaseFetch(authorsUrl)).then((data) => {
const users = data.data as Ref<User[]>;
users.value.sort((a, b) => {
return rawArticle.authors.indexOf(a.id) - rawArticle.authors.indexOf(b.id);
});
rawArticle.authors
? useAsyncData(authorsUrl, () => useBaseFetch(authorsUrl)).then((data) => {
const users = data.data as Ref<User[]>
users.value.sort((a, b) => {
return rawArticle.authors.indexOf(a.id) - rawArticle.authors.indexOf(b.id)
})
return users;
})
: Promise.resolve(),
rawArticle.html(),
]);
return users
})
: Promise.resolve(),
rawArticle.html(),
])
const article = computed(() => ({
...rawArticle,
path: `/news/${rawArticle.slug}`,
thumbnail: rawArticle.thumbnail
? `/news/article/${rawArticle.slug}/thumbnail.webp`
: `/news/default.webp`,
title: rawArticle.title,
summary: rawArticle.summary,
date: rawArticle.date,
html,
}));
...rawArticle,
path: `/news/${rawArticle.slug}`,
thumbnail: rawArticle.thumbnail
? `/news/article/${rawArticle.slug}/thumbnail.webp`
: `/news/default.webp`,
title: rawArticle.title,
summary: rawArticle.summary,
date: rawArticle.date,
html,
}))
const authorCount = computed(() => authors?.value?.length ?? 0);
const authorCount = computed(() => authors?.value?.length ?? 0)
const articleTitle = computed(() => article.value.title);
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
const articleTitle = computed(() => article.value.title)
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`)
const thumbnailPath = computed(() =>
article.value.thumbnail
? `${config.public.siteUrl}${article.value.thumbnail}`
: `${config.public.siteUrl}/news/default.jpg`,
);
article.value.thumbnail
? `${config.public.siteUrl}${article.value.thumbnail}`
: `${config.public.siteUrl}/news/default.jpg`,
)
const dayjsDate = computed(() => dayjs(article.value.date));
const dayjsDate = computed(() => dayjs(article.value.date))
useSeoMeta({
title: () => `${articleTitle.value} - Modrinth News`,
ogTitle: () => articleTitle.value,
description: () => article.value.summary,
ogDescription: () => article.value.summary,
ogType: "article",
ogImage: () => thumbnailPath.value,
articlePublishedTime: () => dayjsDate.value.toISOString(),
twitterCard: "summary_large_image",
twitterImage: () => thumbnailPath.value,
});
title: () => `${articleTitle.value} - Modrinth News`,
ogTitle: () => articleTitle.value,
description: () => article.value.summary,
ogDescription: () => article.value.summary,
ogType: 'article',
ogImage: () => thumbnailPath.value,
articlePublishedTime: () => dayjsDate.value.toISOString(),
twitterCard: 'summary_large_image',
twitterImage: () => thumbnailPath.value,
})
</script>
<template>
<div class="page experimental-styles-within py-6">
<div
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
>
<nuxt-link :to="`/news`">
<h1 class="m-0 text-3xl font-extrabold hover:underline">News</h1>
</nuxt-link>
<div class="flex gap-2">
<NewsletterButton />
<ButtonStyled circular>
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
<RssIcon />
</a>
</ButtonStyled>
<ButtonStyled circular icon-only>
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
<GitGraphIcon />
</a>
</ButtonStyled>
</div>
</div>
<article class="mt-6 flex flex-col gap-4 px-6">
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
<div class="mt-auto flex flex-wrap items-center gap-1 text-sm text-secondary sm:text-base">
<template v-for="(author, index) in authors" :key="`author-${author.id}`">
<span v-if="authorCount - 1 === index && authorCount > 1">and</span>
<span class="flex items-center">
<nuxt-link
:to="`/user/${author.id}`"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar :src="author.avatar_url" circle size="24px" />
{{ author.username }}
</nuxt-link>
<span v-if="(authors?.length ?? 0) > 2 && index !== authorCount - 1">,</span>
</span>
</template>
<template v-if="!authors || authorCount === 0">
<nuxt-link
to="/organization/modrinth"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar src="https://cdn-raw.modrinth.com/modrinth-icon-96.webp" size="24px" />
Modrinth Team
</nuxt-link>
</template>
<span class="hidden md:block"></span>
<span class="hidden md:block"> {{ dayjsDate.format("MMMM D, YYYY") }}</span>
</div>
<span class="text-sm text-secondary sm:text-base md:hidden">
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}</span
>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
<img
:src="article.thumbnail"
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
:alt="article.title"
/>
<div class="markdown-body" v-html="article.html" />
<h3
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
>
Share this article
</h3>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
</article>
</div>
<div class="page experimental-styles-within py-6">
<div
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
>
<nuxt-link :to="`/news`">
<h1 class="m-0 text-3xl font-extrabold hover:underline">News</h1>
</nuxt-link>
<div class="flex gap-2">
<NewsletterButton />
<ButtonStyled circular>
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
<RssIcon />
</a>
</ButtonStyled>
<ButtonStyled circular icon-only>
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
<GitGraphIcon />
</a>
</ButtonStyled>
</div>
</div>
<article class="mt-6 flex flex-col gap-4 px-6">
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">
{{ article.title }}
</h2>
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
<div class="mt-auto flex flex-wrap items-center gap-1 text-sm text-secondary sm:text-base">
<template v-for="(author, index) in authors" :key="`author-${author.id}`">
<span v-if="authorCount - 1 === index && authorCount > 1">and</span>
<span class="flex items-center">
<nuxt-link
:to="`/user/${author.id}`"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar :src="author.avatar_url" circle size="24px" />
{{ author.username }}
</nuxt-link>
<span v-if="(authors?.length ?? 0) > 2 && index !== authorCount - 1">,</span>
</span>
</template>
<template v-if="!authors || authorCount === 0">
<nuxt-link
to="/organization/modrinth"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar src="https://cdn-raw.modrinth.com/modrinth-icon-96.webp" size="24px" />
Modrinth Team
</nuxt-link>
</template>
<span class="hidden md:block"></span>
<span class="hidden md:block"> {{ dayjsDate.format('MMMM D, YYYY') }}</span>
</div>
<span class="text-sm text-secondary sm:text-base md:hidden">
Posted on {{ dayjsDate.format('MMMM D, YYYY') }}</span
>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
<img
:src="article.thumbnail"
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
:alt="article.title"
/>
<div class="markdown-body" v-html="article.html" />
<h3
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
>
Share this article
</h3>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
</article>
</div>
</template>
<style lang="scss" scoped>
.page {
> *:not(.full-width-bg),
> .full-width-bg > * {
max-width: 56rem;
margin-inline: auto;
}
> *:not(.full-width-bg),
> .full-width-bg > * {
max-width: 56rem;
margin-inline: auto;
}
}
.brand-gradient-bg {
background: var(--brand-gradient-bg);
border-color: var(--brand-gradient-border);
background: var(--brand-gradient-bg);
border-color: var(--brand-gradient-border);
}
@media (max-width: 640px) {
.page {
padding-top: 1rem;
padding-bottom: 1rem;
}
.page {
padding-top: 1rem;
padding-bottom: 1rem;
}
article {
gap: 1rem;
}
article {
gap: 1rem;
}
}
:deep(.markdown-body) {
h1,
h2 {
border-bottom: none;
padding: 0;
}
h1,
h2 {
border-bottom: none;
padding: 0;
}
ul > li:not(:last-child) {
margin-bottom: 0.5rem;
}
ul > li:not(:last-child) {
margin-bottom: 0.5rem;
}
ul,
ol {
p {
margin-bottom: 0.5rem;
}
}
ul,
ol {
p {
margin-bottom: 0.5rem;
}
}
ul,
ol {
strong {
color: var(--color-contrast);
font-weight: 600;
}
}
ul,
ol {
strong {
color: var(--color-contrast);
font-weight: 600;
}
}
h1,
h2,
h3 {
margin-bottom: 0.25rem;
}
h1,
h2,
h3 {
margin-bottom: 0.25rem;
}
h1 {
font-size: 1.5rem;
@media (min-width: 640px) {
font-size: 2rem;
}
}
h1 {
font-size: 1.5rem;
@media (min-width: 640px) {
font-size: 2rem;
}
}
h2 {
font-size: 1.25rem;
@media (min-width: 640px) {
font-size: 1.5rem;
}
}
h2 {
font-size: 1.25rem;
@media (min-width: 640px) {
font-size: 1.5rem;
}
}
h3 {
font-size: 1.125rem;
@media (min-width: 640px) {
font-size: 1.25rem;
}
}
h3 {
font-size: 1.125rem;
@media (min-width: 640px) {
font-size: 1.25rem;
}
}
p {
margin-bottom: 1.25rem;
font-size: 0.875rem;
@media (min-width: 640px) {
font-size: 1rem;
}
}
p {
margin-bottom: 1.25rem;
font-size: 0.875rem;
@media (min-width: 640px) {
font-size: 1rem;
}
}
a {
color: var(--color-brand);
font-weight: 600;
a {
color: var(--color-brand);
font-weight: 600;
&:hover {
text-decoration: underline;
}
}
&:hover {
text-decoration: underline;
}
}
h1,
h2 {
a {
font-weight: 800;
}
}
h1,
h2 {
a {
font-weight: 800;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
a {
color: var(--color-contrast);
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
a {
color: var(--color-contrast);
}
}
img {
border: 1px solid var(--color-button-border);
border-radius: var(--radius-md);
@media (min-width: 640px) {
border-radius: var(--radius-lg);
}
}
img {
border: 1px solid var(--color-button-border);
border-radius: var(--radius-md);
@media (min-width: 640px) {
border-radius: var(--radius-lg);
}
}
> img,
> :has(img:first-child:last-child) {
display: flex;
justify-content: center;
}
> img,
> :has(img:first-child:last-child) {
display: flex;
justify-content: center;
}
@media (max-width: 640px) {
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0.5rem;
}
@media (max-width: 640px) {
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0.5rem;
}
p {
margin-bottom: 1rem;
}
p {
margin-bottom: 1rem;
}
ul,
ol {
padding-left: 1.25rem;
}
ul,
ol {
padding-left: 1.25rem;
}
pre {
overflow-x: auto;
font-size: 0.75rem;
}
pre {
overflow-x: auto;
font-size: 0.75rem;
}
table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
}
</style>

View File

@@ -1,31 +1,31 @@
<template>
<div class="page experimental-styles-within">
<h1 class="m-0 text-3xl font-extrabold">Changelog</h1>
<p class="my-3">Keep up-to-date on what's new with Modrinth.</p>
<NuxtPage />
</div>
<div class="page experimental-styles-within">
<h1 class="m-0 text-3xl font-extrabold">Changelog</h1>
<p class="my-3">Keep up-to-date on what's new with Modrinth.</p>
<NuxtPage />
</div>
</template>
<script lang="ts" setup>
const config = useRuntimeConfig();
const config = useRuntimeConfig()
useSeoMeta({
title: "Modrinth Changelog",
ogTitle: "Modrinth Changelog",
description: "Keep up-to-date on what's new with Modrinth.",
ogDescription: "Keep up-to-date on what's new with Modrinth.",
ogType: "website",
ogImage: () => `${config.public.siteUrl}/news/changelog.webp`,
twitterCard: "summary_large_image",
twitterImage: () => `${config.public.siteUrl}/news/changelog.webp`,
});
title: 'Modrinth Changelog',
ogTitle: 'Modrinth Changelog',
description: "Keep up-to-date on what's new with Modrinth.",
ogDescription: "Keep up-to-date on what's new with Modrinth.",
ogType: 'website',
ogImage: () => `${config.public.siteUrl}/news/changelog.webp`,
twitterCard: 'summary_large_image',
twitterImage: () => `${config.public.siteUrl}/news/changelog.webp`,
})
</script>
<style lang="scss" scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
</style>

View File

@@ -1,46 +1,46 @@
<script setup lang="ts">
import { getChangelog } from "@modrinth/utils";
import { ChangelogEntry, Timeline } from "@modrinth/ui";
import { ChevronLeftIcon } from "@modrinth/assets";
import { ChevronLeftIcon } from '@modrinth/assets'
import { ChangelogEntry, Timeline } from '@modrinth/ui'
import { getChangelog } from '@modrinth/utils'
const route = useRoute();
const route = useRoute()
const changelogEntry = computed(() =>
route.params.date
? getChangelog().find((x) => {
if (x.product === route.params.product) {
console.log("Found matching product!");
route.params.date
? getChangelog().find((x) => {
if (x.product === route.params.product) {
console.log('Found matching product!')
if (x.version && x.version === route.params.date) {
console.log("Found matching version!");
return x;
} else if (x.date.unix() === Number(route.params.date as string)) {
console.log("Found matching date!");
return x;
}
}
return undefined;
})
: undefined,
);
if (x.version && x.version === route.params.date) {
console.log('Found matching version!')
return x
} else if (x.date.unix() === Number(route.params.date as string)) {
console.log('Found matching date!')
return x
}
}
return undefined
})
: undefined,
)
const isFirst = computed(() => changelogEntry.value?.date === getChangelog()[0].date);
const isFirst = computed(() => changelogEntry.value?.date === getChangelog()[0].date)
if (!changelogEntry.value) {
createError({ statusCode: 404, statusMessage: "Version not found" });
createError({ statusCode: 404, statusMessage: 'Version not found' })
}
</script>
<template>
<div v-if="changelogEntry">
<nuxt-link
:to="`/news/changelog?filter=${changelogEntry.product}`"
class="mb-4 mt-4 flex w-fit items-center gap-2 text-link"
>
<ChevronLeftIcon /> View full changelog
</nuxt-link>
<Timeline fade-out-end :fade-out-start="!isFirst">
<ChangelogEntry :entry="changelogEntry" :first="isFirst" show-type />
</Timeline>
</div>
<div v-if="changelogEntry">
<nuxt-link
:to="`/news/changelog?filter=${changelogEntry.product}`"
class="mb-4 mt-4 flex w-fit items-center gap-2 text-link"
>
<ChevronLeftIcon /> View full changelog
</nuxt-link>
<Timeline fade-out-end :fade-out-start="!isFirst">
<ChangelogEntry :entry="changelogEntry" :first="isFirst" show-type />
</Timeline>
</div>
</template>

View File

@@ -1,65 +1,66 @@
<script setup lang="ts">
import { type Product, getChangelog } from "@modrinth/utils";
import { ChangelogEntry } from "@modrinth/ui";
import Timeline from "@modrinth/ui/src/components/base/Timeline.vue";
import NavTabs from "~/components/ui/NavTabs.vue";
import { ChangelogEntry } from '@modrinth/ui'
import Timeline from '@modrinth/ui/src/components/base/Timeline.vue'
import { getChangelog, type Product } from '@modrinth/utils'
const route = useRoute();
import NavTabs from '~/components/ui/NavTabs.vue'
const filter = ref<Product | undefined>(undefined);
const allChangelogEntries = ref(getChangelog());
const route = useRoute()
const filter = ref<Product | undefined>(undefined)
const allChangelogEntries = ref(getChangelog())
function updateFilter() {
if (route.query.filter) {
filter.value = route.query.filter as Product;
} else {
filter.value = undefined;
}
if (route.query.filter) {
filter.value = route.query.filter as Product
} else {
filter.value = undefined
}
}
updateFilter();
updateFilter()
watch(
() => route.query,
() => updateFilter(),
);
() => route.query,
() => updateFilter(),
)
const changelogEntries = computed(() =>
allChangelogEntries.value.filter((x) => !filter.value || x.product === filter.value),
);
allChangelogEntries.value.filter((x) => !filter.value || x.product === filter.value),
)
</script>
<template>
<NavTabs
:links="[
{
label: 'All',
href: '',
},
{
label: 'Website',
href: 'web',
},
{
label: 'Servers',
href: 'servers',
},
{
label: 'App',
href: 'app',
},
]"
query="filter"
class="mb-4"
/>
<Timeline fade-out-end>
<ChangelogEntry
v-for="(entry, index) in changelogEntries"
:key="entry.date"
:entry="entry"
:first="index === 0"
:show-type="filter === undefined"
has-link
/>
</Timeline>
<NavTabs
:links="[
{
label: 'All',
href: '',
},
{
label: 'Website',
href: 'web',
},
{
label: 'Servers',
href: 'servers',
},
{
label: 'App',
href: 'app',
},
]"
query="filter"
class="mb-4"
/>
<Timeline fade-out-end>
<ChangelogEntry
v-for="(entry, index) in changelogEntries"
:key="entry.date"
:entry="entry"
:first="index === 0"
:show-type="filter === undefined"
has-link
/>
</Timeline>
</template>

View File

@@ -1,161 +1,162 @@
<script setup lang="ts">
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
import { ChevronRightIcon, RssIcon, GitGraphIcon } from "@modrinth/assets";
import dayjs from "dayjs";
import { articles as rawArticles } from "@modrinth/blog";
import { computed, ref } from "vue";
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
import { ChevronRightIcon, GitGraphIcon, RssIcon } from '@modrinth/assets'
import { articles as rawArticles } from '@modrinth/blog'
import { ButtonStyled, NewsArticleCard } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import NewsletterButton from '~/components/ui/NewsletterButton.vue'
const articles = ref(
rawArticles
.map((article) => ({
...article,
path: `/news/article/${article.slug}/`,
thumbnail: article.thumbnail
? `/news/article/${article.slug}/thumbnail.webp`
: `/news/default.webp`,
title: article.title,
summary: article.summary,
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
);
rawArticles
.map((article) => ({
...article,
path: `/news/article/${article.slug}/`,
thumbnail: article.thumbnail
? `/news/article/${article.slug}/thumbnail.webp`
: `/news/default.webp`,
title: article.title,
summary: article.summary,
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
)
const featuredArticle = computed(() => articles.value?.[0]);
const config = useRuntimeConfig();
const featuredArticle = computed(() => articles.value?.[0])
const config = useRuntimeConfig()
useSeoMeta({
title: "Modrinth News",
ogTitle: "Modrinth News",
description: "Keep up-to-date on the latest news from Modrinth.",
ogDescription: "Keep up-to-date on the latest news from Modrinth.",
ogType: "website",
ogImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
twitterCard: "summary_large_image",
twitterImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
});
title: 'Modrinth News',
ogTitle: 'Modrinth News',
description: 'Keep up-to-date on the latest news from Modrinth.',
ogDescription: 'Keep up-to-date on the latest news from Modrinth.',
ogType: 'website',
ogImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
twitterCard: 'summary_large_image',
twitterImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
})
</script>
<template>
<div class="page experimental-styles-within py-6">
<div class="flex flex-wrap items-center justify-between gap-4 px-6">
<div>
<h1 class="m-0 text-3xl font-extrabold">News</h1>
</div>
<div class="flex gap-2">
<NewsletterButton />
<ButtonStyled circular>
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
<RssIcon />
</a>
</ButtonStyled>
<ButtonStyled circular icon-only>
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
<GitGraphIcon />
</a>
</ButtonStyled>
</div>
</div>
<div class="page experimental-styles-within py-6">
<div class="flex flex-wrap items-center justify-between gap-4 px-6">
<div>
<h1 class="m-0 text-3xl font-extrabold">News</h1>
</div>
<div class="flex gap-2">
<NewsletterButton />
<ButtonStyled circular>
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
<RssIcon />
</a>
</ButtonStyled>
<ButtonStyled circular icon-only>
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
<GitGraphIcon />
</a>
</ButtonStyled>
</div>
</div>
<template v-if="articles && articles.length">
<div
v-if="featuredArticle"
class="full-width-bg brand-gradient-bg mt-6 border-0 border-y-[1px] border-solid py-4"
>
<nuxt-link
:to="`${featuredArticle.path}`"
class="active:scale-[0.99]! group flex cursor-pointer transition-all ease-in-out hover:brightness-125"
>
<article class="featured-article px-6">
<div class="featured-image-container">
<img
:src="featuredArticle.thumbnail"
class="aspect-video w-full rounded-2xl border-[1px] border-solid border-button-border object-cover"
/>
</div>
<div class="featured-content">
<p class="m-0 font-bold">Featured article</p>
<h3 class="m-0 text-3xl leading-tight group-hover:underline">
{{ featuredArticle?.title }}
</h3>
<p class="m-0 text-lg leading-tight">{{ featuredArticle?.summary }}</p>
<div class="mt-auto text-secondary">
{{ dayjs(featuredArticle?.date).format("MMMM D, YYYY") }}
</div>
</div>
</article>
</nuxt-link>
</div>
<template v-if="articles && articles.length">
<div
v-if="featuredArticle"
class="full-width-bg brand-gradient-bg mt-6 border-0 border-y-[1px] border-solid py-4"
>
<nuxt-link
:to="`${featuredArticle.path}`"
class="active:scale-[0.99]! group flex cursor-pointer transition-all ease-in-out hover:brightness-125"
>
<article class="featured-article px-6">
<div class="featured-image-container">
<img
:src="featuredArticle.thumbnail"
class="aspect-video w-full rounded-2xl border-[1px] border-solid border-button-border object-cover"
/>
</div>
<div class="featured-content">
<p class="m-0 font-bold">Featured article</p>
<h3 class="m-0 text-3xl leading-tight group-hover:underline">
{{ featuredArticle?.title }}
</h3>
<p class="m-0 text-lg leading-tight">{{ featuredArticle?.summary }}</p>
<div class="mt-auto text-secondary">
{{ dayjs(featuredArticle?.date).format('MMMM D, YYYY') }}
</div>
</div>
</article>
</nuxt-link>
</div>
<div class="mt-6 px-6">
<div class="group flex w-fit items-center gap-1">
<h2 class="m-0 text-xl font-extrabold">More articles</h2>
<ChevronRightIcon
v-if="false"
class="ml-0 h-6 w-6 transition-all group-hover:ml-1 group-hover:text-brand"
/>
</div>
<div class="mt-6 px-6">
<div class="group flex w-fit items-center gap-1">
<h2 class="m-0 text-xl font-extrabold">More articles</h2>
<ChevronRightIcon
v-if="false"
class="ml-0 h-6 w-6 transition-all group-hover:ml-1 group-hover:text-brand"
/>
</div>
<div class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4">
<NewsArticleCard
v-for="article in articles.slice(1)"
:key="article.path"
:article="article"
/>
</div>
</div>
</template>
<div class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4">
<NewsArticleCard
v-for="article in articles.slice(1)"
:key="article.path"
:article="article"
/>
</div>
</div>
</template>
<div v-else class="pt-4">Error: Articles could not be loaded.</div>
</div>
<div v-else class="pt-4">Error: Articles could not be loaded.</div>
</div>
</template>
<style lang="scss" scoped>
.page {
> *:not(.full-width-bg),
> .full-width-bg > * {
max-width: 56rem;
margin-inline: auto;
}
> *:not(.full-width-bg),
> .full-width-bg > * {
max-width: 56rem;
margin-inline: auto;
}
}
.brand-gradient-bg {
background: var(--brand-gradient-bg);
border-color: var(--brand-gradient-border);
background: var(--brand-gradient-bg);
border-color: var(--brand-gradient-border);
}
.featured-article {
display: flex;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
}
.featured-image-container {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.featured-content {
flex: 1;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (max-width: 640px) {
.featured-article {
flex-direction: column;
}
.featured-article {
flex-direction: column;
}
.featured-image-container {
order: 1;
}
.featured-image-container {
order: 1;
}
.featured-content {
order: 2;
min-width: 0;
}
.featured-content {
order: 2;
min-width: 0;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,28 @@
<template>
<div class="normal-page__content">
<div class="universal-card">
<h2>Analytics</h2>
<div class="normal-page__content">
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your organization's projects. You can see the number
of downloads, page views and revenue earned for all of your projects, as well as the total
downloads and page views for each project by country.
</p>
</div>
<p>
This page shows you the analytics for your organization's projects. You can see the number
of downloads, page views and revenue earned for all of your projects, as well as the total
downloads and page views for each project by country.
</p>
</div>
<ChartDisplay :projects="projects.map((x) => ({ title: x.name, ...x }))" />
</div>
<ChartDisplay :projects="projects.map((x) => ({ title: x.name, ...x }))" />
</div>
</template>
<script setup>
import ChartDisplay from "~/components/ui/charts/ChartDisplay.vue";
import { injectOrganizationContext } from "~/providers/organization-context.ts";
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
import { injectOrganizationContext } from '~/providers/organization-context.ts'
const { projects } = injectOrganizationContext();
const { projects } = injectOrganizationContext()
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
margin-bottom: var(--gap-md);
}
</style>

View File

@@ -1,219 +1,220 @@
<script setup>
import { SaveIcon, TrashIcon, UploadIcon } from "@modrinth/assets";
import { Avatar, Button, ConfirmModal, FileInput, injectNotificationManager } from "@modrinth/ui";
import { injectOrganizationContext } from "~/providers/organization-context.ts";
import { SaveIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { Avatar, Button, ConfirmModal, FileInput, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
import { injectOrganizationContext } from '~/providers/organization-context.ts'
const { addNotification } = injectNotificationManager()
const {
organization,
refresh: refreshOrganization,
hasPermission,
deleteIcon,
patchIcon,
patchOrganization,
} = injectOrganizationContext();
organization,
refresh: refreshOrganization,
hasPermission,
deleteIcon,
patchIcon,
patchOrganization,
} = injectOrganizationContext()
const icon = ref(null);
const deletedIcon = ref(false);
const previewImage = ref(null);
const icon = ref(null)
const deletedIcon = ref(false)
const previewImage = ref(null)
const name = ref(organization.value.name);
const slug = ref(organization.value.slug);
const name = ref(organization.value.name)
const slug = ref(organization.value.slug)
const summary = ref(organization.value.description);
const summary = ref(organization.value.description)
const patchData = computed(() => {
const data = {};
if (name.value !== organization.value.name) {
data.name = name.value;
}
if (slug.value !== organization.value.slug) {
data.slug = slug.value;
}
if (summary.value !== organization.value.description) {
data.description = summary.value;
}
return data;
});
const data = {}
if (name.value !== organization.value.name) {
data.name = name.value
}
if (slug.value !== organization.value.slug) {
data.slug = slug.value
}
if (summary.value !== organization.value.description) {
data.description = summary.value
}
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value;
});
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const markIconForDeletion = () => {
deletedIcon.value = true;
icon.value = null;
previewImage.value = null;
};
deletedIcon.value = true
icon.value = null
previewImage.value = null
}
const showPreviewImage = (files) => {
const reader = new FileReader();
const reader = new FileReader()
icon.value = files[0];
deletedIcon.value = false;
icon.value = files[0]
deletedIcon.value = false
reader.readAsDataURL(icon.value);
reader.onload = (event) => {
previewImage.value = event.target.result;
};
};
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
const orgId = useRouteId();
const orgId = useRouteId()
const onSaveChanges = useClientTry(async () => {
if (hasChanges.value) {
await patchOrganization(orgId, patchData.value);
}
if (hasChanges.value) {
await patchOrganization(orgId, patchData.value)
}
if (deletedIcon.value) {
await deleteIcon();
deletedIcon.value = false;
} else if (icon.value) {
await patchIcon(icon.value);
icon.value = null;
}
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
}
await refreshOrganization();
await refreshOrganization()
addNotification({
title: "Organization updated",
text: "Your organization has been updated.",
type: "success",
});
});
addNotification({
title: 'Organization updated',
text: 'Your organization has been updated.',
type: 'success',
})
})
const onDeleteOrganization = useClientTry(async () => {
await useBaseFetch(`organization/${orgId}`, {
method: "DELETE",
apiVersion: 3,
});
await useBaseFetch(`organization/${orgId}`, {
method: 'DELETE',
apiVersion: 3,
})
addNotification({
title: "Organization deleted",
text: "Your organization has been deleted.",
type: "success",
});
addNotification({
title: 'Organization deleted',
text: 'Your organization has been deleted.',
type: 'success',
})
await navigateTo("/dashboard/organizations");
});
await navigateTo('/dashboard/organizations')
})
</script>
<template>
<div class="normal-page__content">
<ConfirmModal
ref="modal_deletion"
:title="`Are you sure you want to delete ${organization.name}?`"
description="This will delete this organization forever (like *forever* ever)."
:has-to-type="true"
proceed-label="Delete"
:confirmation-text="organization.name"
@proceed="onDeleteOrganization"
/>
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Organization information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : organization.icon_url"
:alt="organization.name"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="btn"
prompt="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<Button
v-if="!deletedIcon && (previewImage || organization.icon_url)"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon />
Remove icon
</Button>
</div>
</div>
<div class="normal-page__content">
<ConfirmModal
ref="modal_deletion"
:title="`Are you sure you want to delete ${organization.name}?`"
description="This will delete this organization forever (like *forever* ever)."
:has-to-type="true"
proceed-label="Delete"
:confirmation-text="organization.name"
@proceed="onDeleteOrganization"
/>
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Organization information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : organization.icon_url"
:alt="organization.name"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="btn"
prompt="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<Button
v-if="!deletedIcon && (previewImage || organization.icon_url)"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon />
Remove icon
</Button>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="project-name"
v-model="name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
/>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="project-name"
v-model="name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
/>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="project-slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="project-slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<Button color="primary" :disabled="!hasChanges" @click="onSaveChanges">
<SaveIcon />
Save changes
</Button>
</div>
</div>
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete organization</span>
</h3>
</div>
<p>
Deleting your organization will transfer all of its projects to the organization owner. This
action cannot be undone.
</p>
<Button color="danger" @click="() => $refs.modal_deletion.show()">
<TrashIcon />
Delete organization
</Button>
</div>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<Button color="primary" :disabled="!hasChanges" @click="onSaveChanges">
<SaveIcon />
Save changes
</Button>
</div>
</div>
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete organization</span>
</h3>
</div>
<p>
Deleting your organization will transfer all of its projects to the organization owner. This
action cannot be undone.
</p>
<Button color="danger" @click="() => $refs.modal_deletion.show()">
<TrashIcon />
Delete organization
</Button>
</div>
</div>
</template>
<style scoped lang="scss">
.summary-input {
min-height: 8rem;
max-width: 24rem;
min-height: 8rem;
max-width: 24rem;
}
</style>

View File

@@ -1,439 +1,440 @@
<template>
<div class="normal-page__content">
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Manage members</span>
</h3>
</div>
<span class="label">
<span class="label__title">Invite a member</span>
<span class="label__description">
Enter the Modrinth username of the person you'd like to invite to be a member of this
organization.
</span>
</span>
<div class="input-group">
<input
id="username"
v-model="currentUsername"
type="text"
placeholder="Username"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.MANAGE_INVITES,
)
"
@keypress.enter="() => onInviteTeamMember(organization.team, currentUsername)"
/>
<label for="username" class="hidden">Username</label>
<Button
color="primary"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.MANAGE_INVITES,
)
"
@click="() => onInviteTeamMember(organization.team_id, currentUsername)"
>
<UserPlusIcon />
Invite
</Button>
</div>
<div class="adjacent-input">
<span class="label">
<span class="label__title">Leave organization</span>
<span class="label__description">
Remove yourself as a member of this organization.
</span>
</span>
<Button
color="danger"
:disabled="currentMember.is_owner"
@click="() => onLeaveProject(organization.team_id, auth.user.id)"
>
<UserRemoveIcon />
Leave organization
</Button>
</div>
</div>
<div
v-for="(member, index) in allTeamMembers"
:key="member.user.id"
class="member universal-card"
:class="{ open: openTeamMembers.includes(member.user.id) }"
>
<div class="member-header">
<div class="info">
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="sm" circle />
<div class="text">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p>{{ member.user.username }}</p>
<CrownIcon v-if="member.is_owner" v-tooltip="'Organization owner'" />
</nuxt-link>
<p>{{ member.role }}</p>
</div>
</div>
<div class="side-buttons">
<Badge v-if="member.accepted" type="accepted" />
<Badge v-else type="pending" />
<Button
icon-only
transparent
class="dropdown-icon"
@click="
openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id)
: (openTeamMembers = openTeamMembers.filter((it) => it !== member.user.id))
"
>
<DropdownIcon />
</Button>
</div>
</div>
<div class="content">
<div class="adjacent-input">
<label :for="`member-${member.user.id}-role`">
<span class="label__title">Role</span>
<span class="label__description">
The title of the role that this member plays for this organization.
</span>
</label>
<input
:id="`member-${member.user.id}-role`"
v-model="member.role"
type="text"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
)
"
/>
</div>
<div class="adjacent-input">
<label :for="`member-${member.user.id}-monetization-weight`">
<span class="label__title">Monetization weight</span>
<span class="label__description">
Relative to all other members' monetization weights, this determines what portion of
the organization projects' revenue goes to this member.
</span>
</label>
<input
:id="`member-${member.user.id}-monetization-weight`"
v-model="member.payouts_split"
type="number"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
)
"
/>
</div>
<template v-if="!member.is_owner">
<span class="label">
<span class="label__title">Project permissions</span>
</span>
<div class="permissions">
<Checkbox
v-for="[label, permission] in Object.entries(projectPermissions)"
:key="permission"
:model-value="isPermission(member.permissions, permission)"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER_DEFAULT_PERMISSIONS,
) || !isPermission(currentMember.permissions, permission)
"
:label="permToLabel(label)"
@update:model-value="allTeamMembers[index].permissions ^= permission"
/>
</div>
</template>
<template v-if="!member.is_owner">
<span class="label">
<span class="label__title">Organization permissions</span>
</span>
<div class="permissions">
<Checkbox
v-for="[label, permission] in Object.entries(organizationPermissions)"
:key="permission"
:model-value="isPermission(member.organization_permissions, permission)"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
) || !isPermission(currentMember.organization_permissions, permission)
"
:label="permToLabel(label)"
@update:model-value="allTeamMembers[index].organization_permissions ^= permission"
/>
</div>
</template>
<div class="input-group">
<Button
color="primary"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
)
"
@click="onUpdateTeamMember(organization.team_id, member)"
>
<SaveIcon />
Save changes
</Button>
<Button
v-if="!member.is_owner"
color="danger"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
) &&
!isPermission(
currentMember.organization_permissions,
organizationPermissions.REMOVE_MEMBER,
)
"
@click="onRemoveMember(organization.team_id, member)"
>
<UserRemoveIcon />
Remove member
</Button>
<Button
v-if="!member.is_owner && currentMember.is_owner && member.accepted"
@click="() => onTransferOwnership(organization.team_id, member.user.id)"
>
<TransferIcon />
Transfer ownership
</Button>
</div>
</div>
</div>
</div>
<div class="normal-page__content">
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Manage members</span>
</h3>
</div>
<span class="label">
<span class="label__title">Invite a member</span>
<span class="label__description">
Enter the Modrinth username of the person you'd like to invite to be a member of this
organization.
</span>
</span>
<div class="input-group">
<input
id="username"
v-model="currentUsername"
type="text"
placeholder="Username"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.MANAGE_INVITES,
)
"
@keypress.enter="() => onInviteTeamMember(organization.team, currentUsername)"
/>
<label for="username" class="hidden">Username</label>
<Button
color="primary"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.MANAGE_INVITES,
)
"
@click="() => onInviteTeamMember(organization.team_id, currentUsername)"
>
<UserPlusIcon />
Invite
</Button>
</div>
<div class="adjacent-input">
<span class="label">
<span class="label__title">Leave organization</span>
<span class="label__description">
Remove yourself as a member of this organization.
</span>
</span>
<Button
color="danger"
:disabled="currentMember.is_owner"
@click="() => onLeaveProject(organization.team_id, auth.user.id)"
>
<UserRemoveIcon />
Leave organization
</Button>
</div>
</div>
<div
v-for="(member, index) in allTeamMembers"
:key="member.user.id"
class="member universal-card"
:class="{ open: openTeamMembers.includes(member.user.id) }"
>
<div class="member-header">
<div class="info">
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="sm" circle />
<div class="text">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p>{{ member.user.username }}</p>
<CrownIcon v-if="member.is_owner" v-tooltip="'Organization owner'" />
</nuxt-link>
<p>{{ member.role }}</p>
</div>
</div>
<div class="side-buttons">
<Badge v-if="member.accepted" type="accepted" />
<Badge v-else type="pending" />
<Button
icon-only
transparent
class="dropdown-icon"
@click="
openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id)
: (openTeamMembers = openTeamMembers.filter((it) => it !== member.user.id))
"
>
<DropdownIcon />
</Button>
</div>
</div>
<div class="content">
<div class="adjacent-input">
<label :for="`member-${member.user.id}-role`">
<span class="label__title">Role</span>
<span class="label__description">
The title of the role that this member plays for this organization.
</span>
</label>
<input
:id="`member-${member.user.id}-role`"
v-model="member.role"
type="text"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
)
"
/>
</div>
<div class="adjacent-input">
<label :for="`member-${member.user.id}-monetization-weight`">
<span class="label__title">Monetization weight</span>
<span class="label__description">
Relative to all other members' monetization weights, this determines what portion of
the organization projects' revenue goes to this member.
</span>
</label>
<input
:id="`member-${member.user.id}-monetization-weight`"
v-model="member.payouts_split"
type="number"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
)
"
/>
</div>
<template v-if="!member.is_owner">
<span class="label">
<span class="label__title">Project permissions</span>
</span>
<div class="permissions">
<Checkbox
v-for="[label, permission] in Object.entries(projectPermissions)"
:key="permission"
:model-value="isPermission(member.permissions, permission)"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER_DEFAULT_PERMISSIONS,
) || !isPermission(currentMember.permissions, permission)
"
:label="permToLabel(label)"
@update:model-value="allTeamMembers[index].permissions ^= permission"
/>
</div>
</template>
<template v-if="!member.is_owner">
<span class="label">
<span class="label__title">Organization permissions</span>
</span>
<div class="permissions">
<Checkbox
v-for="[label, permission] in Object.entries(organizationPermissions)"
:key="permission"
:model-value="isPermission(member.organization_permissions, permission)"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
) || !isPermission(currentMember.organization_permissions, permission)
"
:label="permToLabel(label)"
@update:model-value="allTeamMembers[index].organization_permissions ^= permission"
/>
</div>
</template>
<div class="input-group">
<Button
color="primary"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
)
"
@click="onUpdateTeamMember(organization.team_id, member)"
>
<SaveIcon />
Save changes
</Button>
<Button
v-if="!member.is_owner"
color="danger"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER,
) &&
!isPermission(
currentMember.organization_permissions,
organizationPermissions.REMOVE_MEMBER,
)
"
@click="onRemoveMember(organization.team_id, member)"
>
<UserRemoveIcon />
Remove member
</Button>
<Button
v-if="!member.is_owner && currentMember.is_owner && member.accepted"
@click="() => onTransferOwnership(organization.team_id, member.user.id)"
>
<TransferIcon />
Transfer ownership
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
CrownIcon,
DropdownIcon,
SaveIcon,
TransferIcon,
UserPlusIcon,
UserXIcon as UserRemoveIcon,
} from "@modrinth/assets";
import { Avatar, Badge, Button, Checkbox, injectNotificationManager } from "@modrinth/ui";
import { ref } from "vue";
import { removeTeamMember } from "~/helpers/teams.js";
import { injectOrganizationContext } from "~/providers/organization-context.ts";
import { isPermission } from "~/utils/permissions.ts";
CrownIcon,
DropdownIcon,
SaveIcon,
TransferIcon,
UserPlusIcon,
UserXIcon as UserRemoveIcon,
} from '@modrinth/assets'
import { Avatar, Badge, Button, Checkbox, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
const { addNotification } = injectNotificationManager();
const { organization, refresh: refreshOrganization, currentMember } = injectOrganizationContext();
import { removeTeamMember } from '~/helpers/teams.js'
import { injectOrganizationContext } from '~/providers/organization-context.ts'
import { isPermission } from '~/utils/permissions.ts'
const auth = await useAuth();
const { addNotification } = injectNotificationManager()
const { organization, refresh: refreshOrganization, currentMember } = injectOrganizationContext()
const currentUsername = ref("");
const openTeamMembers = ref([]);
const auth = await useAuth()
const allTeamMembers = ref(organization.value.members);
const currentUsername = ref('')
const openTeamMembers = ref([])
const allTeamMembers = ref(organization.value.members)
watch(
() => organization.value,
() => {
allTeamMembers.value = organization.value.members;
},
);
() => organization.value,
() => {
allTeamMembers.value = organization.value.members
},
)
const projectPermissions = {
UPLOAD_VERSION: 1 << 0,
DELETE_VERSION: 1 << 1,
EDIT_DETAILS: 1 << 2,
EDIT_BODY: 1 << 3,
MANAGE_INVITES: 1 << 4,
REMOVE_MEMBER: 1 << 5,
EDIT_MEMBER: 1 << 6,
DELETE_PROJECT: 1 << 7,
VIEW_ANALYTICS: 1 << 8,
VIEW_PAYOUTS: 1 << 9,
};
UPLOAD_VERSION: 1 << 0,
DELETE_VERSION: 1 << 1,
EDIT_DETAILS: 1 << 2,
EDIT_BODY: 1 << 3,
MANAGE_INVITES: 1 << 4,
REMOVE_MEMBER: 1 << 5,
EDIT_MEMBER: 1 << 6,
DELETE_PROJECT: 1 << 7,
VIEW_ANALYTICS: 1 << 8,
VIEW_PAYOUTS: 1 << 9,
}
const organizationPermissions = {
EDIT_DETAILS: 1 << 0,
MANAGE_INVITES: 1 << 1,
REMOVE_MEMBER: 1 << 2,
EDIT_MEMBER: 1 << 3,
ADD_PROJECT: 1 << 4,
REMOVE_PROJECT: 1 << 5,
DELETE_ORGANIZATION: 1 << 6,
EDIT_MEMBER_DEFAULT_PERMISSIONS: 1 << 7,
};
EDIT_DETAILS: 1 << 0,
MANAGE_INVITES: 1 << 1,
REMOVE_MEMBER: 1 << 2,
EDIT_MEMBER: 1 << 3,
ADD_PROJECT: 1 << 4,
REMOVE_PROJECT: 1 << 5,
DELETE_ORGANIZATION: 1 << 6,
EDIT_MEMBER_DEFAULT_PERMISSIONS: 1 << 7,
}
const permToLabel = (key) => {
const o = key.split("_").join(" ");
return o.charAt(0).toUpperCase() + o.slice(1).toLowerCase();
};
const o = key.split('_').join(' ')
return o.charAt(0).toUpperCase() + o.slice(1).toLowerCase()
}
const leaveProject = async (teamId, uid) => {
await removeTeamMember(teamId, uid);
await navigateTo(`/organization/${organization.value.id}`);
};
await removeTeamMember(teamId, uid)
await navigateTo(`/organization/${organization.value.id}`)
}
const onLeaveProject = useClientTry(leaveProject);
const onLeaveProject = useClientTry(leaveProject)
const onInviteTeamMember = useClientTry(async (teamId, username) => {
const user = await useBaseFetch(`user/${username}`);
const data = {
user_id: user.id.trim(),
};
await useBaseFetch(`team/${teamId}/members`, {
method: "POST",
body: data,
});
await refreshOrganization();
currentUsername.value = "";
addNotification({
title: "Member invited",
text: `${user.username} has been invited to the organization.`,
type: "success",
});
});
const user = await useBaseFetch(`user/${username}`)
const data = {
user_id: user.id.trim(),
}
await useBaseFetch(`team/${teamId}/members`, {
method: 'POST',
body: data,
})
await refreshOrganization()
currentUsername.value = ''
addNotification({
title: 'Member invited',
text: `${user.username} has been invited to the organization.`,
type: 'success',
})
})
const onRemoveMember = useClientTry(async (teamId, member) => {
await removeTeamMember(teamId, member.user.id);
await refreshOrganization();
addNotification({
title: "Member removed",
text: `${member.user.username} has been removed from the organization.`,
type: "success",
});
});
await removeTeamMember(teamId, member.user.id)
await refreshOrganization()
addNotification({
title: 'Member removed',
text: `${member.user.username} has been removed from the organization.`,
type: 'success',
})
})
const onUpdateTeamMember = useClientTry(async (teamId, member) => {
const data = !member.is_owner
? {
permissions: member.permissions,
organization_permissions: member.organization_permissions,
role: member.role,
payouts_split: member.payouts_split,
}
: {
payouts_split: member.payouts_split,
role: member.role,
};
await useBaseFetch(`team/${teamId}/members/${member.user.id}`, {
method: "PATCH",
body: data,
});
await refreshOrganization();
addNotification({
title: "Member updated",
text: `${member.user.username} has been updated.`,
type: "success",
});
});
const data = !member.is_owner
? {
permissions: member.permissions,
organization_permissions: member.organization_permissions,
role: member.role,
payouts_split: member.payouts_split,
}
: {
payouts_split: member.payouts_split,
role: member.role,
}
await useBaseFetch(`team/${teamId}/members/${member.user.id}`, {
method: 'PATCH',
body: data,
})
await refreshOrganization()
addNotification({
title: 'Member updated',
text: `${member.user.username} has been updated.`,
type: 'success',
})
})
const onTransferOwnership = useClientTry(async (teamId, uid) => {
const data = {
user_id: uid,
};
await useBaseFetch(`team/${teamId}/owner`, {
method: "PATCH",
body: data,
});
await refreshOrganization();
addNotification({
title: "Ownership transferred",
text: `The ownership of ${organization.value.name} has been successfully transferred.`,
type: "success",
});
});
const data = {
user_id: uid,
}
await useBaseFetch(`team/${teamId}/owner`, {
method: 'PATCH',
body: data,
})
await refreshOrganization()
addNotification({
title: 'Ownership transferred',
text: `The ownership of ${organization.value.name} has been successfully transferred.`,
type: 'success',
})
})
</script>
<style lang="scss" scoped>
.member {
.member-header {
display: flex;
justify-content: space-between;
.member-header {
display: flex;
justify-content: space-between;
.info {
display: flex;
.info {
display: flex;
.text {
margin: auto 0 auto 0.5rem;
font-size: var(--font-size-sm);
.text {
margin: auto 0 auto 0.5rem;
font-size: var(--font-size-sm);
.name {
font-weight: bold;
.name {
font-weight: bold;
display: flex;
align-items: center;
gap: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
svg {
color: var(--color-orange);
}
}
svg {
color: var(--color-orange);
}
}
p {
margin: 0.2rem 0;
}
}
}
p {
margin: 0.2rem 0;
}
}
}
.side-buttons {
display: flex;
align-items: center;
.side-buttons {
display: flex;
align-items: center;
.dropdown-icon {
margin-left: 1rem;
.dropdown-icon {
margin-left: 1rem;
svg {
transition: 150ms ease transform;
}
}
}
}
svg {
transition: 150ms ease transform;
}
}
}
}
.content {
display: none;
flex-direction: column;
padding-top: var(--gap-md);
.content {
display: none;
flex-direction: column;
padding-top: var(--gap-md);
.main-info {
margin-bottom: var(--gap-lg);
}
.main-info {
margin-bottom: var(--gap-lg);
}
.permissions {
margin-bottom: var(--gap-md);
max-width: 45rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0.5rem;
}
}
.permissions {
margin-bottom: var(--gap-md);
max-width: 45rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0.5rem;
}
}
&.open {
.member-header {
.dropdown-icon svg {
transform: rotate(180deg);
}
}
&.open {
.member-header {
.dropdown-icon svg {
transform: rotate(180deg);
}
}
.content {
display: flex;
}
}
.content {
display: flex;
}
}
}
:deep(.checkbox-outer) {
button.checkbox {
border: none;
}
button.checkbox {
border: none;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,170 +1,165 @@
<template>
<PurchaseModal
ref="purchaseModal"
:product="midasProduct"
:country="country"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) =>
await useBaseFetch('billing/payment', { internal: true, method: 'POST', body })
"
:fetch-payment-data="fetchPaymentData"
:on-error="
(err) =>
addNotification({
title: 'An error occurred',
type: 'error',
text: err.message ?? (err.data ? err.data.description : err),
})
"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<div class="main-hero">
<div class="flex max-w-screen-lg flex-col items-center gap-4 text-center">
<ModrinthPlusIcon class="h-8 w-max text-contrast" />
<h1 class="m-0 text-[4rem]">Support creators and go ad-free</h1>
<p class="m-0 mb-4 text-[18px] leading-relaxed">
Subscribe to Modrinth Plus to go ad-free, support Modrinth's development, and get an
exclusive profile badge! Half your subscription goes directly to Modrinth creators. Cancel
anytime.
</p>
<p class="m-0 text-[2rem] font-bold text-purple">
{{ formatPrice(vintl.locale, price.prices.intervals.monthly, price.currency_code) }}/mo
</p>
<p class="m-0 mb-4 text-secondary">
or save
{{ calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly) }}% with
annual billing!
</p>
<nuxt-link
v-if="auth.user && isPermission(auth.user.badges, 1 << 0)"
to="/settings/billing"
class="btn btn-purple btn-large"
>
<SettingsIcon aria-hidden="true" />
Manage subscription
</nuxt-link>
<button v-else-if="auth.user" class="btn btn-purple btn-large" @click="purchaseModal.show()">
Subscribe
</button>
<nuxt-link
v-else
:to="`/auth/sign-in?redirect=${encodeURIComponent('/plus?showModal=true')}`"
class="btn btn-purple btn-large"
>
Subscribe
</nuxt-link>
</div>
</div>
<div class="perks-hero">
<h2>What you get with Modrinth Plus!</h2>
<div class="mt-8 grid max-w-screen-lg gap-8 lg:grid-cols-3">
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-4">
<HeartIcon class="h-8 w-8 text-purple" />
<span class="text-lg font-bold">Support Modrinth creators</span>
<span class="leading-5 text-secondary">
50% of your subscription goes directly to Modrinth creators.
</span>
</div>
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-4">
<SparklesIcon class="h-8 w-8 text-purple" />
<span class="text-lg font-bold">Remove all ads</span>
<span class="leading-5 text-secondary">
Never see an advertisement again on the Modrinth app.
</span>
</div>
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-4">
<StarIcon class="h-8 w-8 text-purple" />
<span class="text-lg font-bold">Profile badge</span>
<span class="leading-5 text-secondary">Get an exclusive badge on your user page.</span>
</div>
</div>
<span class="mt-4 text-secondary">...and much more coming soon™!</span>
</div>
<PurchaseModal
ref="purchaseModal"
:product="midasProduct"
:country="country"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) =>
await useBaseFetch('billing/payment', { internal: true, method: 'POST', body })
"
:fetch-payment-data="fetchPaymentData"
:on-error="
(err) =>
addNotification({
title: 'An error occurred',
type: 'error',
text: err.message ?? (err.data ? err.data.description : err),
})
"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<div class="main-hero">
<div class="flex max-w-screen-lg flex-col items-center gap-4 text-center">
<ModrinthPlusIcon class="h-8 w-max text-contrast" />
<h1 class="m-0 text-[4rem]">Support creators and go ad-free</h1>
<p class="m-0 mb-4 text-[18px] leading-relaxed">
Subscribe to Modrinth Plus to go ad-free, support Modrinth's development, and get an
exclusive profile badge! Half your subscription goes directly to Modrinth creators. Cancel
anytime.
</p>
<p class="m-0 text-[2rem] font-bold text-purple">
{{ formatPrice(vintl.locale, price.prices.intervals.monthly, price.currency_code) }}/mo
</p>
<p class="m-0 mb-4 text-secondary">
or save
{{ calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly) }}% with
annual billing!
</p>
<nuxt-link
v-if="auth.user && isPermission(auth.user.badges, 1 << 0)"
to="/settings/billing"
class="btn btn-purple btn-large"
>
<SettingsIcon aria-hidden="true" />
Manage subscription
</nuxt-link>
<button v-else-if="auth.user" class="btn btn-purple btn-large" @click="purchaseModal.show()">
Subscribe
</button>
<nuxt-link
v-else
:to="`/auth/sign-in?redirect=${encodeURIComponent('/plus?showModal=true')}`"
class="btn btn-purple btn-large"
>
Subscribe
</nuxt-link>
</div>
</div>
<div class="perks-hero">
<h2>What you get with Modrinth Plus!</h2>
<div class="mt-8 grid max-w-screen-lg gap-8 lg:grid-cols-3">
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-4">
<HeartIcon class="h-8 w-8 text-purple" />
<span class="text-lg font-bold">Support Modrinth creators</span>
<span class="leading-5 text-secondary">
50% of your subscription goes directly to Modrinth creators.
</span>
</div>
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-4">
<SparklesIcon class="h-8 w-8 text-purple" />
<span class="text-lg font-bold">Remove all ads</span>
<span class="leading-5 text-secondary">
Never see an advertisement again on the Modrinth app.
</span>
</div>
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-4">
<StarIcon class="h-8 w-8 text-purple" />
<span class="text-lg font-bold">Profile badge</span>
<span class="leading-5 text-secondary">Get an exclusive badge on your user page.</span>
</div>
</div>
<span class="mt-4 text-secondary">...and much more coming soon™!</span>
</div>
</template>
<script setup>
import {
HeartIcon,
ModrinthPlusIcon,
SettingsIcon,
SparklesIcon,
StarIcon,
} from "@modrinth/assets";
import { injectNotificationManager, PurchaseModal } from "@modrinth/ui";
import { calculateSavings, formatPrice, getCurrency } from "@modrinth/utils";
import { products } from "~/generated/state.json";
import { HeartIcon, ModrinthPlusIcon, SettingsIcon, SparklesIcon, StarIcon } from '@modrinth/assets'
import { injectNotificationManager, PurchaseModal } from '@modrinth/ui'
import { calculateSavings, formatPrice, getCurrency } from '@modrinth/utils'
const { addNotification } = injectNotificationManager();
import { products } from '~/generated/state.json'
const title = "Subscribe to Modrinth Plus!";
const { addNotification } = injectNotificationManager()
const title = 'Subscribe to Modrinth Plus!'
const description =
"Subscribe to Modrinth Plus to go ad-free, support Modrinth's development, and get an exclusive profile badge! Half your subscription goes directly to Modrinth creators.";
"Subscribe to Modrinth Plus to go ad-free, support Modrinth's development, and get an exclusive profile badge! Half your subscription goes directly to Modrinth creators."
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
});
title,
description,
ogTitle: title,
ogDescription: description,
})
useHead({
script: [
{
src: "https://js.stripe.com/v3/",
defer: true,
async: true,
},
],
});
script: [
{
src: 'https://js.stripe.com/v3/',
defer: true,
async: true,
},
],
})
const vintl = useVIntl();
const vintl = useVIntl()
const config = useRuntimeConfig();
const config = useRuntimeConfig()
const auth = await useAuth();
const purchaseModal = ref();
const midasProduct = ref(products.find((x) => x.metadata.type === "midas"));
const country = useUserCountry();
const auth = await useAuth()
const purchaseModal = ref()
const midasProduct = ref(products.find((x) => x.metadata.type === 'midas'))
const country = useUserCountry()
const price = computed(() =>
midasProduct.value.prices.find((x) => x.currency_code === getCurrency(country.value)),
);
const customer = ref();
const paymentMethods = ref([]);
midasProduct.value.prices.find((x) => x.currency_code === getCurrency(country.value)),
)
const customer = ref()
const paymentMethods = ref([])
async function fetchPaymentData() {
[customer.value, paymentMethods.value] = await Promise.all([
useBaseFetch("billing/customer", { internal: true }),
useBaseFetch("billing/payment_methods", { internal: true }),
]);
;[customer.value, paymentMethods.value] = await Promise.all([
useBaseFetch('billing/customer', { internal: true }),
useBaseFetch('billing/payment_methods', { internal: true }),
])
}
const route = useRoute();
const route = useRoute()
onMounted(() => {
if (route.query.showModal) {
purchaseModal.value.show();
}
});
if (route.query.showModal) {
purchaseModal.value.show()
}
})
</script>
<style lang="scss" scoped>
.main-hero {
background:
linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
var(--color-accent-contrast);
margin-top: -5rem;
padding: 11.25rem 1rem 8rem;
background:
linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
var(--color-accent-contrast);
margin-top: -5rem;
padding: 11.25rem 1rem 8rem;
display: flex;
align-items: center;
flex-direction: column;
display: flex;
align-items: center;
flex-direction: column;
}
.perks-hero {
background-color: var(--color-accent-contrast);
display: flex;
align-items: center;
flex-direction: column;
padding: 4rem 1rem;
background-color: var(--color-accent-contrast);
display: flex;
align-items: center;
flex-direction: column;
padding: 4rem 1rem;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
<template>
<div class="flex h-full w-full flex-col">
<div
class="flex items-center justify-between gap-2 border-0 border-b border-solid border-bg-raised p-3"
>
<h2 class="m-0 text-2xl font-bold text-contrast">Admin</h2>
</div>
</div>
<div class="flex h-full w-full flex-col">
<div
class="flex items-center justify-between gap-2 border-0 border-b border-solid border-bg-raised p-3"
>
<h2 class="m-0 text-2xl font-bold text-contrast">Admin</h2>
</div>
</div>
</template>
<script setup lang="ts"></script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,347 +1,348 @@
<template>
<div
v-if="server.moduleErrors.backups"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.backups.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<BackupCreateModal ref="createBackupModal" :server="server" />
<BackupRenameModal ref="renameBackupModal" :server="server" />
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
<div
v-if="server.moduleErrors.backups"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.backups.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<BackupCreateModal ref="createBackupModal" :server="server" />
<BackupRenameModal ref="renameBackupModal" :server="server" />
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<TagItem
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
class="cursor-help"
:style="{
'--_color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange)'
: undefined,
'--_bg-color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red-bg)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange-bg)'
: undefined,
}"
>
{{ data.used_backup_quota }} / {{ data.backup_quota }}
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
>
<ButtonStyled type="standard">
<button
v-tooltip="
'Auto backups are currently unavailable; we apologize for the inconvenience.'
"
:disabled="true || server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupCreationDisabled"
class="w-full sm:w-fit"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<TagItem
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
class="cursor-help"
:style="{
'--_color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange)'
: undefined,
'--_bg-color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red-bg)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange-bg)'
: undefined,
}"
>
{{ data.used_backup_quota }} / {{ data.backup_quota }}
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
>
<ButtonStyled type="standard">
<button
v-tooltip="
'Auto backups are currently unavailable; we apologize for the inconvenience.'
"
:disabled="true || server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupCreationDisabled"
class="w-full sm:w-fit"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
<div class="flex w-full flex-col gap-2">
<div
v-if="backups.length === 0"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="data.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else> You don't have any backups yet. </template>
</div>
<BackupItem
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@prepare="() => prepareDownload(backup.id)"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</div>
<div class="flex w-full flex-col gap-2">
<div
v-if="backups.length === 0"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="data.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else> You don't have any backups yet. </template>
</div>
<BackupItem
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@prepare="() => prepareDownload(backup.id)"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id)
} else {
lockBackup(backup.id)
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, IssuesIcon, PlusIcon, SettingsIcon, SpinnerIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager, TagItem } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { useStorage } from "@vueuse/core";
import { computed, ref } from "vue";
import BackupCreateModal from "~/components/ui/servers/BackupCreateModal.vue";
import BackupDeleteModal from "~/components/ui/servers/BackupDeleteModal.vue";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import BackupRenameModal from "~/components/ui/servers/BackupRenameModal.vue";
import BackupRestoreModal from "~/components/ui/servers/BackupRestoreModal.vue";
import BackupSettingsModal from "~/components/ui/servers/BackupSettingsModal.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { DownloadIcon, IssuesIcon, PlusIcon, SettingsIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, TagItem } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
const { addNotification } = injectNotificationManager();
import BackupCreateModal from '~/components/ui/servers/BackupCreateModal.vue'
import BackupDeleteModal from '~/components/ui/servers/BackupDeleteModal.vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import BackupRenameModal from '~/components/ui/servers/BackupRenameModal.vue'
import BackupRestoreModal from '~/components/ui/servers/BackupRestoreModal.vue'
import BackupSettingsModal from '~/components/ui/servers/BackupSettingsModal.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
isServerRunning: boolean;
}>();
server: ModrinthServer
isServerRunning: boolean
}>()
const route = useNativeRoute();
const serverId = route.params.id;
const route = useNativeRoute()
const serverId = route.params.id
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
backupWhileRunning: false,
});
backupWhileRunning: false,
})
defineEmits(["onDownload"]);
defineEmits(['onDownload'])
const data = computed(() => props.server.general);
const data = computed(() => props.server.general)
const backups = computed(() => {
if (!props.server.backups?.data) return [];
if (!props.server.backups?.data) return []
return [...props.server.backups.data].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
});
return [...props.server.backups.data].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
useHead({
title: `Backups - ${data.value?.name ?? "Server"} - Modrinth`,
});
title: `Backups - ${data.value?.name ?? 'Server'} - Modrinth`,
})
const overTheTopDownloadAnimation = ref();
const overTheTopDownloadAnimation = ref()
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>();
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>();
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>();
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>();
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>();
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>()
const backupCreationDisabled = computed(() => {
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return "Cannot create backup while server is running";
}
if (
data.value?.used_backup_quota !== undefined &&
data.value?.backup_quota !== undefined &&
data.value?.used_backup_quota >= data.value?.backup_quota
) {
return `All ${data.value.backup_quota} of your backup slots are in use`;
}
if (backups.value.some((backup) => backup.task?.create?.state === "ongoing")) {
return "A backup is already in progress";
}
if (props.server.general?.status === "installing") {
return "Cannot create backup while server is installing";
}
return undefined;
});
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return 'Cannot create backup while server is running'
}
if (
data.value?.used_backup_quota !== undefined &&
data.value?.backup_quota !== undefined &&
data.value?.used_backup_quota >= data.value?.backup_quota
) {
return `All ${data.value.backup_quota} of your backup slots are in use`
}
if (backups.value.some((backup) => backup.task?.create?.state === 'ongoing')) {
return 'A backup is already in progress'
}
if (props.server.general?.status === 'installing') {
return 'Cannot create backup while server is installing'
}
return undefined
})
const showCreateModel = () => {
createBackupModal.value?.show();
};
createBackupModal.value?.show()
}
const showbackupSettingsModal = () => {
backupSettingsModal.value?.show();
};
backupSettingsModal.value?.show()
}
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true;
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500);
overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const prepareDownload = async (backupId: string) => {
try {
await props.server.backups?.prepare(backupId);
} catch (error) {
console.error("Failed to prepare download:", error);
addNotification({
type: "error",
title: "Failed to prepare backup for download",
text: error as string,
});
}
};
try {
await props.server.backups?.prepare(backupId)
} catch (error) {
console.error('Failed to prepare download:', error)
addNotification({
type: 'error',
title: 'Failed to prepare backup for download',
text: error as string,
})
}
}
const lockBackup = async (backupId: string) => {
try {
await props.server.backups?.lock(backupId);
await props.server.refresh(["backups"]);
} catch (error) {
console.error("Failed to toggle lock:", error);
}
};
try {
await props.server.backups?.lock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const unlockBackup = async (backupId: string) => {
try {
await props.server.backups?.unlock(backupId);
await props.server.refresh(["backups"]);
} catch (error) {
console.error("Failed to toggle lock:", error);
}
};
try {
await props.server.backups?.unlock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const retryBackup = async (backupId: string) => {
try {
await props.server.backups?.retry(backupId);
await props.server.refresh(["backups"]);
} catch (error) {
console.error("Failed to retry backup:", error);
}
};
try {
await props.server.backups?.retry(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to retry backup:', error)
}
}
async function deleteBackup(backup?: Backup) {
if (!backup) {
addNotification({
type: "error",
title: "Error deleting backup",
text: "Backup is null",
});
return;
}
if (!backup) {
addNotification({
type: 'error',
title: 'Error deleting backup',
text: 'Backup is null',
})
return
}
try {
await props.server.backups?.delete(backup.id);
await props.server.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addNotification({
type: "error",
title: "Error deleting backup",
text: message,
});
}
try {
await props.server.backups?.delete(backup.id)
await props.server.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({
type: 'error',
title: 'Error deleting backup',
text: message,
})
}
}
</script>
<style scoped>
.over-the-top-download-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
&.animation-hidden {
scale: 0.8;
opacity: 0;
&.animation-hidden {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
</style>

View File

@@ -1,21 +1,21 @@
<template>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
</template>
<script setup lang="ts">
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const route = useNativeRoute();
const route = useNativeRoute()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const data = computed(() => props.server.general);
const data = computed(() => props.server.general)
useHead({
title: `Content - ${data.value?.name ?? "Server"} - Modrinth`,
});
title: `Content - ${data.value?.name ?? 'Server'} - Modrinth`,
})
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +1,70 @@
<template>
<UiServersServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
<UiServersServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
</template>
<script setup lang="ts">
import {
InfoIcon,
ListIcon,
SettingsIcon,
TextQuoteIcon,
VersionIcon,
CardIcon,
UserIcon,
WrenchIcon,
ModrinthIcon,
} from "@modrinth/assets";
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
CardIcon,
InfoIcon,
ListIcon,
ModrinthIcon,
SettingsIcon,
TextQuoteIcon,
UserIcon,
VersionIcon,
WrenchIcon,
} from '@modrinth/assets'
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
const route = useRoute();
const serverId = route.params.id as string;
const auth = await useAuth();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const route = useRoute()
const serverId = route.params.id as string
const auth = await useAuth()
const props = defineProps<{
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
});
title: `Options - ${props.server.general?.name ?? 'Server'} - Modrinth`,
})
const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
const ownerId = computed(() => props.server.general?.owner_id ?? 'Ghost')
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value)
const isAdmin = computed(() => isUserAdmin(auth.value?.user))
const navLinks = computed(() => [
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: "Network", href: `/servers/manage/${serverId}/options/network` },
{ icon: ListIcon, label: "Properties", href: `/servers/manage/${serverId}/options/properties` },
{
icon: UserIcon,
label: "Preferences",
href: `/servers/manage/${serverId}/options/preferences`,
},
{
icon: CardIcon,
label: "Billing",
href: `/settings/billing#server-${serverId}`,
external: true,
shown: isOwner.value,
},
{
icon: ModrinthIcon,
label: "Admin Billing",
href: `/admin/billing/${ownerId.value}`,
external: true,
shown: isAdmin.value,
},
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
]);
{ icon: SettingsIcon, label: 'General', href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: 'Platform', href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: 'Startup', href: `/servers/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: 'Network', href: `/servers/manage/${serverId}/options/network` },
{ icon: ListIcon, label: 'Properties', href: `/servers/manage/${serverId}/options/properties` },
{
icon: UserIcon,
label: 'Preferences',
href: `/servers/manage/${serverId}/options/preferences`,
},
{
icon: CardIcon,
label: 'Billing',
href: `/settings/billing#server-${serverId}`,
external: true,
shown: isOwner.value,
},
{
icon: ModrinthIcon,
label: 'Admin Billing',
href: `/admin/billing/${ownerId.value}`,
external: true,
shown: isAdmin.value,
},
{ icon: InfoIcon, label: 'Info', href: `/servers/manage/${serverId}/options/info` },
])
</script>

View File

@@ -1,12 +1,12 @@
<template>
<div class="universal-card">
<p>You can manage your server's billing from Settings > Billing and subscriptions.</p>
<ButtonStyled>
<NuxtLink to="/settings/billing">Go to Billing</NuxtLink>
</ButtonStyled>
</div>
<div class="universal-card">
<p>You can manage your server's billing from Settings > Billing and subscriptions.</p>
<ButtonStyled>
<NuxtLink to="/settings/billing">Go to Billing</NuxtLink>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ButtonStyled } from '@modrinth/ui'
</script>

View File

@@ -1,30 +1,30 @@
<template>
<div class="relative h-full w-full overflow-y-auto">
<div v-if="data" class="flex h-full w-full flex-col">
<div class="gap-2">
<div class="card flex flex-col gap-4">
<label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server name</span>
<span> This name is only visible on Modrinth.</span>
</label>
<div class="flex flex-col gap-2">
<input
id="server-name-field"
v-model="serverName"
class="w-full md:w-[50%]"
maxlength="48"
minlength="1"
@keyup.enter="!serverName && saveGeneral"
/>
<span v-if="!serverName" class="text-sm text-rose-400">
Server name must be at least 1 character long.
</span>
<span v-if="!isValidServerName" class="text-sm text-rose-400">
Server name can contain any character.
</span>
</div>
</div>
<!-- WIP - disable for now
<div class="relative h-full w-full overflow-y-auto">
<div v-if="data" class="flex h-full w-full flex-col">
<div class="gap-2">
<div class="card flex flex-col gap-4">
<label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server name</span>
<span> This name is only visible on Modrinth.</span>
</label>
<div class="flex flex-col gap-2">
<input
id="server-name-field"
v-model="serverName"
class="w-full md:w-[50%]"
maxlength="48"
minlength="1"
@keyup.enter="!serverName && saveGeneral"
/>
<span v-if="!serverName" class="text-sm text-rose-400">
Server name must be at least 1 character long.
</span>
<span v-if="!isValidServerName" class="text-sm text-rose-400">
Server name can contain any character.
</span>
</div>
</div>
<!-- WIP - disable for now
<div class="card flex flex-col gap-4">
<label for="server-motd-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server MOTD</span>
@@ -36,294 +36,293 @@
</div>
-->
<div class="card flex flex-col gap-4">
<label for="server-subdomain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Custom URL</span>
<span> Your friends can connect to your server using this URL. </span>
</label>
<div class="flex w-full items-center gap-2 md:w-[60%]">
<input
id="server-subdomain"
v-model="serverSubdomain"
class="h-[50%] w-[63%]"
maxlength="32"
@keyup.enter="saveGeneral"
/>
.modrinth.gg
</div>
<div v-if="!isValidSubdomain" class="flex flex-col text-sm text-rose-400">
<span v-if="!isValidLengthSubdomain">
Subdomain must be at least 5 characters long.
</span>
<span v-if="!isValidCharsSubdomain">
Subdomain can only contain alphanumeric characters and dashes.
</span>
</div>
</div>
<div class="card flex flex-col gap-4">
<label for="server-subdomain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Custom URL</span>
<span> Your friends can connect to your server using this URL. </span>
</label>
<div class="flex w-full items-center gap-2 md:w-[60%]">
<input
id="server-subdomain"
v-model="serverSubdomain"
class="h-[50%] w-[63%]"
maxlength="32"
@keyup.enter="saveGeneral"
/>
.modrinth.gg
</div>
<div v-if="!isValidSubdomain" class="flex flex-col text-sm text-rose-400">
<span v-if="!isValidLengthSubdomain">
Subdomain must be at least 5 characters long.
</span>
<span v-if="!isValidCharsSubdomain">
Subdomain can only contain alphanumeric characters and dashes.
</span>
</div>
</div>
<div class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span>
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
</label>
<div class="flex gap-4">
<div
v-tooltip="'Upload a custom Icon'"
class="group relative flex w-fit cursor-pointer items-center gap-2 rounded-xl bg-table-alternateRow"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
v-if="icon"
id="server-icon-field"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
@change="uploadFile"
/>
<div
class="absolute top-0 hidden size-[6rem] flex-col items-center justify-center rounded-xl bg-button-bg p-2 opacity-80 group-hover:flex"
>
<EditIcon class="h-8 w-8 text-contrast" />
</div>
<UiServersServerIcon :image="icon" />
</div>
<ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
<TransferIcon class="h-6 w-6" />
<span>Sync icon</span>
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else />
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
<div class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span>
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
</label>
<div class="flex gap-4">
<div
v-tooltip="'Upload a custom Icon'"
class="group relative flex w-fit cursor-pointer items-center gap-2 rounded-xl bg-table-alternateRow"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
v-if="icon"
id="server-icon-field"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
@change="uploadFile"
/>
<div
class="absolute top-0 hidden size-[6rem] flex-col items-center justify-center rounded-xl bg-button-bg p-2 opacity-80 group-hover:flex"
>
<EditIcon class="h-8 w-8 text-contrast" />
</div>
<UiServersServerIcon :image="icon" />
</div>
<ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
<TransferIcon class="h-6 w-6" />
<span>Sync icon</span>
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else />
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
</template>
<script setup lang="ts">
import { EditIcon, TransferIcon } from "@modrinth/assets";
import { injectNotificationManager } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { EditIcon, TransferIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const data = computed(() => props.server.general);
const serverName = ref(data.value?.name);
const serverSubdomain = ref(data.value?.net?.domain ?? "");
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5);
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value));
const isValidSubdomain = computed(
() => isValidLengthSubdomain.value && isValidCharsSubdomain.value,
);
const icon = computed(() => data.value?.image);
const data = computed(() => props.server.general)
const serverName = ref(data.value?.name)
const serverSubdomain = ref(data.value?.net?.domain ?? '')
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value))
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
const icon = computed(() => data.value?.image)
const isUpdating = ref(false);
const isUpdating = ref(false)
const hasUnsavedChanges = computed(
() =>
(serverName.value && serverName.value !== data.value?.name) ||
serverSubdomain.value !== data.value?.net?.domain,
);
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0);
() =>
(serverName.value && serverName.value !== data.value?.name) ||
serverSubdomain.value !== data.value?.net?.domain,
)
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0)
watch(serverName, (oldValue) => {
if (!isValidServerName.value) {
serverName.value = oldValue;
}
});
if (!isValidServerName.value) {
serverName.value = oldValue
}
})
const saveGeneral = async () => {
if (!isValidServerName.value || !isValidSubdomain.value) return;
if (!isValidServerName.value || !isValidSubdomain.value) return
try {
isUpdating.value = true;
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? "");
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const available = await props.server.network?.checkSubdomainAvailability(
serverSubdomain.value,
);
try {
isUpdating.value = true
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? '')
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const available = await props.server.network?.checkSubdomainAvailability(
serverSubdomain.value,
)
if (!available) {
addNotification({
type: "error",
title: "Subdomain not available",
text: "The subdomain you entered is already in use.",
});
return;
}
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
})
return
}
await props.server.network?.changeSubdomain(serverSubdomain.value);
} catch (error) {
console.error("Error checking subdomain availability:", error);
addNotification({
type: "error",
title: "Error checking availability",
text: "Failed to verify if the subdomain is available.",
});
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
await props.server.refresh();
addNotification({
type: "success",
title: "Server settings updated",
text: "Your server settings were successfully changed.",
});
} catch (error) {
console.error(error);
addNotification({
type: "error",
title: "Failed to update server settings",
text: "An error occurred while attempting to update your server settings.",
});
} finally {
isUpdating.value = false;
}
};
await props.server.network?.changeSubdomain(serverSubdomain.value)
} catch (error) {
console.error('Error checking subdomain availability:', error)
addNotification({
type: 'error',
title: 'Error checking availability',
text: 'Failed to verify if the subdomain is available.',
})
return
}
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server settings',
text: 'An error occurred while attempting to update your server settings.',
})
} finally {
isUpdating.value = false
}
}
const resetGeneral = () => {
serverName.value = data.value?.name || "";
serverSubdomain.value = data.value?.net?.domain ?? "";
};
serverName.value = data.value?.name || ''
serverSubdomain.value = data.value?.net?.domain ?? ''
}
const uploadFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) {
addNotification({
type: "error",
title: "No file selected",
text: "Please select a file to upload.",
});
return;
}
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) {
addNotification({
type: 'error',
title: 'No file selected',
text: 'Please select a file to upload.',
})
return
}
const scaledFile = await new Promise<File>((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], "server-icon.png", { type: "image/png" }));
} else {
reject(new Error("Canvas toBlob failed"));
}
}, "image/png");
URL.revokeObjectURL(img.src);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
const scaledFile = await new Promise<File>((resolve, reject) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = 64
canvas.height = 64
ctx?.drawImage(img, 0, 0, 64, 64)
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], 'server-icon.png', { type: 'image/png' }))
} else {
reject(new Error('Canvas toBlob failed'))
}
}, 'image/png')
URL.revokeObjectURL(img.src)
}
img.onerror = reject
img.src = URL.createObjectURL(file)
})
try {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
}
try {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
}
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file);
await props.server.fs?.uploadFile('/server-icon.png', scaledFile)
await props.server.fs?.uploadFile('/server-icon-original.png', file)
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
useState(`server-icon-${props.server.serverId}`).value = dataURL;
if (data.value) data.value.image = dataURL;
resolve();
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512
canvas.height = 512
ctx?.drawImage(img, 0, 0, 512, 512)
const dataURL = canvas.toDataURL('image/png')
useState(`server-icon-${props.server.serverId}`).value = dataURL
if (data.value) data.value.image = dataURL
resolve()
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(file)
})
addNotification({
type: "success",
title: "Server icon updated",
text: "Your server icon was successfully changed.",
});
} catch (error) {
console.error("Error uploading icon:", error);
addNotification({
type: "error",
title: "Upload failed",
text: "Failed to upload server icon.",
});
}
};
addNotification({
type: 'success',
title: 'Server icon updated',
text: 'Your server icon was successfully changed.',
})
} catch (error) {
console.error('Error uploading icon:', error)
addNotification({
type: 'error',
title: 'Upload failed',
text: 'Failed to upload server icon.',
})
}
}
const resetIcon = async () => {
if (data.value?.image) {
try {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
if (data.value?.image) {
try {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
useState(`server-icon-${props.server.serverId}`).value = undefined;
if (data.value) data.value.image = undefined;
useState(`server-icon-${props.server.serverId}`).value = undefined
if (data.value) data.value.image = undefined
await props.server.refresh(["general"]);
await props.server.refresh(['general'])
addNotification({
type: "success",
title: "Server icon reset",
text: "Your server icon was successfully reset.",
});
} catch (error) {
console.error("Error resetting icon:", error);
addNotification({
type: "error",
title: "Reset failed",
text: "Failed to reset server icon.",
});
}
}
};
addNotification({
type: 'success',
title: 'Server icon reset',
text: 'Your server icon was successfully reset.',
})
} catch (error) {
console.error('Error resetting icon:', error)
addNotification({
type: 'error',
title: 'Reset failed',
text: 'Failed to reset server icon.',
})
}
}
}
const onDragOver = (e: DragEvent) => {
e.preventDefault();
};
e.preventDefault()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault();
};
e.preventDefault()
}
const onDrop = (e: DragEvent) => {
e.preventDefault();
uploadFile(e);
};
e.preventDefault()
uploadFile(e)
}
const triggerFileInput = () => {
const input = document.createElement("input");
input.type = "file";
input.id = "server-icon-field";
input.accept = "image/png,image/jpeg,image/gif,image/webp";
input.onchange = uploadFile;
input.click();
};
const input = document.createElement('input')
input.type = 'file'
input.id = 'server-icon-field'
input.accept = 'image/png,image/jpeg,image/gif,image/webp'
input.onchange = uploadFile
input.click()
}
</script>

View File

@@ -1,154 +1,157 @@
<template>
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card">
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">SFTP</span>
<span> SFTP allows you to access your server's files from outside of Modrinth. </span>
</label>
<ButtonStyled>
<button
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
@click="openSftp"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</button>
</ButtonStyled>
</div>
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card">
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">SFTP</span>
<span> SFTP allows you to access your server's files from outside of Modrinth. </span>
</label>
<ButtonStyled>
<button
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
@click="openSftp"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</button>
</ButtonStyled>
</div>
<div
class="flex w-full flex-row justify-between gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex flex-col gap-2">
<span class="cursor-pointer font-bold text-contrast">
{{ data?.sftp_host }}
</span>
<div
class="flex w-full flex-row justify-between gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex flex-col gap-2">
<span class="cursor-pointer font-bold text-contrast">
{{ data?.sftp_host }}
</span>
<span class="text-xs text-secondary">Server Address</span>
</div>
<span class="text-xs text-secondary">Server Address</span>
</div>
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP server address'"
@click="copyToClipboard('Server address', data?.sftp_host)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
>
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{ data?.sftp_username }}
</span>
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP server address'"
@click="copyToClipboard('Server address', data?.sftp_host)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
>
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{ data?.sftp_username }}
</span>
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP username'"
@click="copyToClipboard('Username', data?.sftp_username)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
<span class="text-xs text-secondary">Username</span>
</div>
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{
showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0)
}}
</span>
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP username'"
@click="copyToClipboard('Username', data?.sftp_username)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
<span class="text-xs text-secondary">Username</span>
</div>
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{
showPassword ? data?.sftp_password : '*'.repeat(data?.sftp_password?.length ?? 0)
}}
</span>
<div class="flex flex-row items-center gap-1">
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP password'"
@click="copyToClipboard('Password', data?.sftp_password)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
@click="togglePassword"
>
<EyeIcon v-if="showPassword" class="h-5 w-5 hover:cursor-pointer" />
<EyeOffIcon v-else class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
</div>
<span class="text-xs text-secondary">Password</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2 class="text-xl font-bold">Info</h2>
<div class="rounded-xl bg-table-alternateRow p-4">
<table
class="min-w-full border-collapse overflow-hidden rounded-lg border-2 border-gray-300"
>
<tbody>
<tr v-for="property in properties" :key="property.name">
<td v-if="property.value !== 'Unknown'" class="py-3">{{ property.name }}</td>
<td v-if="property.value !== 'Unknown'" class="px-4">
<CopyCode :text="property.value" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="flex flex-row items-center gap-1">
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP password'"
@click="copyToClipboard('Password', data?.sftp_password)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
@click="togglePassword"
>
<EyeIcon v-if="showPassword" class="h-5 w-5 hover:cursor-pointer" />
<EyeOffIcon v-else class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
</div>
<span class="text-xs text-secondary">Password</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2 class="text-xl font-bold">Info</h2>
<div class="rounded-xl bg-table-alternateRow p-4">
<table
class="min-w-full border-collapse overflow-hidden rounded-lg border-2 border-gray-300"
>
<tbody>
<tr v-for="property in properties" :key="property.name">
<td v-if="property.value !== 'Unknown'" class="py-3">
{{ property.name }}
</td>
<td v-if="property.value !== 'Unknown'" class="px-4">
<CopyCode :text="property.value" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from "@modrinth/assets";
import { ButtonStyled, CopyCode, injectNotificationManager } from "@modrinth/ui";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const data = computed(() => props.server.general);
const showPassword = ref(false);
const data = computed(() => props.server.general)
const showPassword = ref(false)
const openSftp = () => {
const sftpUrl = `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`;
window.open(sftpUrl, "_blank");
};
const sftpUrl = `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`
window.open(sftpUrl, '_blank')
}
const togglePassword = () => {
showPassword.value = !showPassword.value;
};
showPassword.value = !showPassword.value
}
const copyToClipboard = (name: string, textToCopy?: string) => {
navigator.clipboard.writeText(textToCopy || "");
addNotification({
type: "success",
title: `${name} copied to clipboard!`,
});
};
navigator.clipboard.writeText(textToCopy || '')
addNotification({
type: 'success',
title: `${name} copied to clipboard!`,
})
}
const properties = [
{ name: "Server ID", value: props.server.serverId ?? "Unknown" },
{ name: "Node", value: data.value?.node?.instance ?? "Unknown" },
{ name: "Kind", value: data.value?.upstream?.kind ?? data.value?.loader ?? "Unknown" },
{ name: "Project ID", value: data.value?.upstream?.project_id ?? "Unknown" },
{ name: "Version ID", value: data.value?.upstream?.version_id ?? "Unknown" },
];
{ name: 'Server ID', value: props.server.serverId ?? 'Unknown' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
{ name: 'Kind', value: data.value?.upstream?.kind ?? data.value?.loader ?? 'Unknown' },
{ name: 'Project ID', value: data.value?.upstream?.project_id ?? 'Unknown' },
{ name: 'Version ID', value: data.value?.upstream?.version_id ?? 'Unknown' },
]
</script>

View File

@@ -1,22 +1,22 @@
<template>
<ServerInstallation
:server="props.server"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
<ServerInstallation
:server="props.server"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
</template>
<script setup lang="ts">
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const props = defineProps<{
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
</script>

View File

@@ -1,492 +1,490 @@
<template>
<div class="contents">
<NewModal ref="newAllocationModal" header="New allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="addNewAllocation">
<label for="new-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="new-allocation-name"
ref="newAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<PlusIcon /> Create allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="newAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<div class="contents">
<NewModal ref="newAllocationModal" header="New allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="addNewAllocation">
<label for="new-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="new-allocation-name"
ref="newAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<PlusIcon /> Create allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="newAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="editAllocationModal" header="Edit allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="edit-allocation-name"
ref="editAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<SaveIcon /> Update allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="editAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="editAllocationModal" header="Edit allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="edit-allocation-name"
ref="editAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<SaveIcon /> Update allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="editAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<ConfirmModal
ref="confirmDeleteModal"
title="Deleting allocation"
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
proceed-label="Delete"
@proceed="confirmDeleteAllocation"
/>
<ConfirmModal
ref="confirmDeleteModal"
title="Deleting allocation"
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
proceed-label="Delete"
@proceed="confirmDeleteAllocation"
/>
<div class="relative h-full w-full overflow-y-auto">
<div
v-if="server.moduleErrors.network"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.network.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
<div class="flex h-full flex-col">
<!-- Subdomain section -->
<div class="card flex flex-col gap-4">
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
<label for="user-domain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Generated DNS records</span>
<span>
Set up your personal domain to connect to your server via custom DNS records.
</span>
</label>
<div class="relative h-full w-full overflow-y-auto">
<div
v-if="server.moduleErrors.network"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.network.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
<div class="flex h-full flex-col">
<!-- Subdomain section -->
<div class="card flex flex-col gap-4">
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
<label for="user-domain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Generated DNS records</span>
<span>
Set up your personal domain to connect to your server via custom DNS records.
</span>
</label>
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="userDomain == ''"
@click="exportDnsRecords"
>
<UploadIcon />
<span>Export DNS records</span>
</button>
</ButtonStyled>
</div>
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="userDomain == ''"
@click="exportDnsRecords"
>
<UploadIcon />
<span>Export DNS records</span>
</button>
</ButtonStyled>
</div>
<input
id="user-domain"
v-model="userDomain"
class="w-full md:w-[50%]"
maxlength="64"
minlength="1"
type="text"
:placeholder="exampleDomain"
/>
<input
id="user-domain"
v-model="userDomain"
class="w-full md:w-[50%]"
maxlength="64"
minlength="1"
type="text"
:placeholder="exampleDomain"
/>
<div
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
>
<table
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
>
<tbody class="w-full">
<tr v-for="record in dnsRecords" :key="record.content" class="w-full">
<td class="w-1/6 py-3 pr-4 md:w-1/5 md:pr-8 lg:w-1/4 lg:pr-12">
<div class="flex flex-col gap-1" @click="copyText(record.type)">
<span
class="text-md font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.type }}
</span>
<span class="text-xs text-secondary">Type</span>
</div>
</td>
<td class="w-2/6 py-3 md:w-1/3">
<div class="flex flex-col gap-1" @click="copyText(record.name)">
<span
class="text-md truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.name }}
</span>
<span class="text-xs text-secondary">Name</span>
</div>
</td>
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
<div class="flex flex-col gap-1" @click="copyText(record.content)">
<span
class="text-md w-fit truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.content }}
</span>
<span class="text-xs text-secondary">Content</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
>
<table
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
>
<tbody class="w-full">
<tr v-for="record in dnsRecords" :key="record.content" class="w-full">
<td class="w-1/6 py-3 pr-4 md:w-1/5 md:pr-8 lg:w-1/4 lg:pr-12">
<div class="flex flex-col gap-1" @click="copyText(record.type)">
<span
class="text-md font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.type }}
</span>
<span class="text-xs text-secondary">Type</span>
</div>
</td>
<td class="w-2/6 py-3 md:w-1/3">
<div class="flex flex-col gap-1" @click="copyText(record.name)">
<span
class="text-md truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.name }}
</span>
<span class="text-xs text-secondary">Name</span>
</div>
</td>
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
<div class="flex flex-col gap-1" @click="copyText(record.content)">
<span
class="text-md w-fit truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.content }}
</span>
<span class="text-xs text-secondary">Content</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
You must own your own domain to use this feature.
</span>
</div>
</div>
<div class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
You must own your own domain to use this feature.
</span>
</div>
</div>
<!-- Allocations section -->
<div class="card flex flex-col gap-4">
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Allocations</span>
<span>
Configure additional ports for internet-facing features like map viewers or voice
chat mods.
</span>
</div>
<!-- Allocations section -->
<div class="card flex flex-col gap-4">
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Allocations</span>
<span>
Configure additional ports for internet-facing features like map viewers or voice
chat mods.
</span>
</div>
<ButtonStyled type="standard" @click="showNewAllocationModal">
<button class="!w-full sm:!w-auto">
<PlusIcon />
<span>New allocation</span>
</button>
</ButtonStyled>
</div>
<ButtonStyled type="standard" @click="showNewAllocationModal">
<button class="!w-full sm:!w-auto">
<PlusIcon />
<span>New allocation</span>
</button>
</ButtonStyled>
</div>
<div class="flex w-full flex-col overflow-hidden rounded-xl bg-table-alternateRow p-4">
<!-- Primary allocation -->
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<span class="text-md font-bold tracking-wide text-contrast">
Primary allocation
</span>
<div class="flex w-full flex-col overflow-hidden rounded-xl bg-table-alternateRow p-4">
<!-- Primary allocation -->
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<span class="text-md font-bold tracking-wide text-contrast">
Primary allocation
</span>
<CopyCode :text="`${serverIP}:${serverPrimaryPort}`" />
</div>
</div>
<CopyCode :text="`${serverIP}:${serverPrimaryPort}`" />
</div>
</div>
<div
v-if="allocations?.[0]"
class="flex w-full flex-col gap-4 overflow-hidden rounded-xl bg-table-alternateRow p-4"
>
<div
v-for="allocation in allocations"
:key="allocation.port"
class="border-border flex flex-col justify-between gap-4 sm:flex-row sm:items-center"
>
<div class="flex flex-row items-center gap-4">
<VersionIcon class="h-7 w-7 flex-none rotate-90" />
<div class="flex w-[20rem] flex-col justify-between sm:flex-row sm:items-center">
<div class="flex flex-col gap-1">
<span class="text-md font-bold tracking-wide text-contrast">
{{ allocation.name }}
</span>
<span class="hidden text-xs text-secondary sm:block">Name</span>
</div>
<div class="flex flex-col gap-1">
<span
class="text-md w-10 tracking-wide text-secondary sm:font-bold sm:text-contrast"
>
{{ allocation.port }}
</span>
<span class="hidden text-xs text-secondary sm:block">Port</span>
</div>
</div>
</div>
<div
v-if="allocations?.[0]"
class="flex w-full flex-col gap-4 overflow-hidden rounded-xl bg-table-alternateRow p-4"
>
<div
v-for="allocation in allocations"
:key="allocation.port"
class="border-border flex flex-col justify-between gap-4 sm:flex-row sm:items-center"
>
<div class="flex flex-row items-center gap-4">
<VersionIcon class="h-7 w-7 flex-none rotate-90" />
<div class="flex w-[20rem] flex-col justify-between sm:flex-row sm:items-center">
<div class="flex flex-col gap-1">
<span class="text-md font-bold tracking-wide text-contrast">
{{ allocation.name }}
</span>
<span class="hidden text-xs text-secondary sm:block">Name</span>
</div>
<div class="flex flex-col gap-1">
<span
class="text-md w-10 tracking-wide text-secondary sm:font-bold sm:text-contrast"
>
{{ allocation.port }}
</span>
<span class="hidden text-xs text-secondary sm:block">Port</span>
</div>
</div>
</div>
<div class="flex w-full flex-row items-center gap-2 sm:w-auto">
<CopyCode :text="`${serverIP}:${allocation.port}`" />
<ButtonStyled icon-only>
<button
class="!w-full sm:!w-auto"
@click="showEditAllocationModal(allocation.port)"
>
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled icon-only color="red">
<button
class="!w-full sm:!w-auto"
@click="showConfirmDeleteModal(allocation.port)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:is-updating="isUpdating"
:save="saveNetwork"
:reset="resetNetwork"
/>
</div>
</div>
<div class="flex w-full flex-row items-center gap-2 sm:w-auto">
<CopyCode :text="`${serverIP}:${allocation.port}`" />
<ButtonStyled icon-only>
<button
class="!w-full sm:!w-auto"
@click="showEditAllocationModal(allocation.port)"
>
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled icon-only color="red">
<button
class="!w-full sm:!w-auto"
@click="showConfirmDeleteModal(allocation.port)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:is-updating="isUpdating"
:save="saveNetwork"
:reset="resetNetwork"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
EditIcon,
InfoIcon,
IssuesIcon,
PlusIcon,
SaveIcon,
TrashIcon,
UploadIcon,
VersionIcon,
} from "@modrinth/assets";
EditIcon,
InfoIcon,
IssuesIcon,
PlusIcon,
SaveIcon,
TrashIcon,
UploadIcon,
VersionIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
CopyCode,
injectNotificationManager,
NewModal,
} from "@modrinth/ui";
import { computed, nextTick, ref } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
ButtonStyled,
ConfirmModal,
CopyCode,
injectNotificationManager,
NewModal,
} from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const isUpdating = ref(false);
const data = computed(() => props.server.general);
const isUpdating = ref(false)
const data = computed(() => props.server.general)
const serverIP = ref(data?.value?.net?.ip ?? "");
const serverSubdomain = ref(data?.value?.net?.domain ?? "");
const serverPrimaryPort = ref(data?.value?.net?.port ?? 0);
const userDomain = ref("");
const exampleDomain = "play.example.com";
const serverIP = ref(data?.value?.net?.ip ?? '')
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
const userDomain = ref('')
const exampleDomain = 'play.example.com'
const network = computed(() => props.server.network);
const allocations = computed(() => network.value?.allocations);
const network = computed(() => props.server.network)
const allocations = computed(() => network.value?.allocations)
const newAllocationModal = ref<typeof NewModal>();
const editAllocationModal = ref<typeof NewModal>();
const confirmDeleteModal = ref<typeof ConfirmModal>();
const newAllocationInput = ref<HTMLInputElement | null>(null);
const editAllocationInput = ref<HTMLInputElement | null>(null);
const newAllocationName = ref("");
const newAllocationPort = ref(0);
const allocationToDelete = ref<number | null>(null);
const newAllocationModal = ref<typeof NewModal>()
const editAllocationModal = ref<typeof NewModal>()
const confirmDeleteModal = ref<typeof ConfirmModal>()
const newAllocationInput = ref<HTMLInputElement | null>(null)
const editAllocationInput = ref<HTMLInputElement | null>(null)
const newAllocationName = ref('')
const newAllocationPort = ref(0)
const allocationToDelete = ref<number | null>(null)
const hasUnsavedChanges = computed(() => serverSubdomain.value !== data?.value?.net?.domain);
const hasUnsavedChanges = computed(() => serverSubdomain.value !== data?.value?.net?.domain)
const isValidSubdomain = computed(() => /^[a-zA-Z0-9-]{5,}$/.test(serverSubdomain.value));
const isValidSubdomain = computed(() => /^[a-zA-Z0-9-]{5,}$/.test(serverSubdomain.value))
const addNewAllocation = async () => {
if (!newAllocationName.value) return;
if (!newAllocationName.value) return
try {
await props.server.network?.reserveAllocation(newAllocationName.value);
await props.server.refresh(["network"]);
try {
await props.server.network?.reserveAllocation(newAllocationName.value)
await props.server.refresh(['network'])
newAllocationModal.value?.hide();
newAllocationName.value = "";
newAllocationModal.value?.hide()
newAllocationName.value = ''
addNotification({
type: "success",
title: "Allocation reserved",
text: "Your allocation has been reserved.",
});
} catch (error) {
console.error("Failed to reserve new allocation:", error);
}
};
addNotification({
type: 'success',
title: 'Allocation reserved',
text: 'Your allocation has been reserved.',
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
}
}
const showNewAllocationModal = () => {
newAllocationName.value = "";
newAllocationModal.value?.show();
nextTick(() => {
setTimeout(() => {
newAllocationInput.value?.focus();
}, 100);
});
};
newAllocationName.value = ''
newAllocationModal.value?.show()
nextTick(() => {
setTimeout(() => {
newAllocationInput.value?.focus()
}, 100)
})
}
const showEditAllocationModal = (port: number) => {
newAllocationPort.value = port;
editAllocationModal.value?.show();
nextTick(() => {
setTimeout(() => {
editAllocationInput.value?.focus();
}, 100);
});
};
newAllocationPort.value = port
editAllocationModal.value?.show()
nextTick(() => {
setTimeout(() => {
editAllocationInput.value?.focus()
}, 100)
})
}
const showConfirmDeleteModal = (port: number) => {
allocationToDelete.value = port;
confirmDeleteModal.value?.show();
};
allocationToDelete.value = port
confirmDeleteModal.value?.show()
}
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return;
if (allocationToDelete.value === null) return
await props.server.network?.deleteAllocation(allocationToDelete.value);
await props.server.refresh(["network"]);
await props.server.network?.deleteAllocation(allocationToDelete.value)
await props.server.refresh(['network'])
addNotification({
type: "success",
title: "Allocation removed",
text: "Your allocation has been removed.",
});
addNotification({
type: 'success',
title: 'Allocation removed',
text: 'Your allocation has been removed.',
})
allocationToDelete.value = null;
};
allocationToDelete.value = null
}
const editAllocation = async () => {
if (!newAllocationName.value) return;
if (!newAllocationName.value) return
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
await props.server.refresh(["network"]);
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
await props.server.refresh(['network'])
editAllocationModal.value?.hide();
newAllocationName.value = "";
editAllocationModal.value?.hide()
newAllocationName.value = ''
addNotification({
type: "success",
title: "Allocation updated",
text: "Your allocation has been updated.",
});
} catch (error) {
console.error("Failed to reserve new allocation:", error);
}
};
addNotification({
type: 'success',
title: 'Allocation updated',
text: 'Your allocation has been updated.',
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
}
}
const saveNetwork = async () => {
if (!isValidSubdomain.value) return;
if (!isValidSubdomain.value) return
try {
isUpdating.value = true;
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value);
if (!available) {
addNotification({
type: "error",
title: "Subdomain not available",
text: "The subdomain you entered is already in use.",
});
return;
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
await props.server.network?.changeSubdomain(serverSubdomain.value);
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(
serverPrimaryPort.value,
newAllocationName.value,
);
}
await new Promise((resolve) => setTimeout(resolve, 500));
await props.server.refresh();
addNotification({
type: "success",
title: "Server settings updated",
text: "Your server settings were successfully changed.",
});
} catch (error) {
console.error(error);
addNotification({
type: "error",
title: "Failed to update server settings",
text: "An error occurred while attempting to update your server settings.",
});
} finally {
isUpdating.value = false;
}
};
try {
isUpdating.value = true
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
})
return
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
await props.server.network?.changeSubdomain(serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server settings',
text: 'An error occurred while attempting to update your server settings.',
})
} finally {
isUpdating.value = false
}
}
const resetNetwork = () => {
serverSubdomain.value = data?.value?.net?.domain ?? "";
};
serverSubdomain.value = data?.value?.net?.domain ?? ''
}
const dnsRecords = computed(() => {
const domain = userDomain.value === "" ? exampleDomain : userDomain.value;
return [
{
type: "A",
name: `${domain}`,
content: data.value?.net?.ip ?? "",
},
{
type: "SRV",
name: `_minecraft._tcp.${domain}`,
content: `0 10 ${data.value?.net?.port} ${domain}`,
},
];
});
const domain = userDomain.value === '' ? exampleDomain : userDomain.value
return [
{
type: 'A',
name: `${domain}`,
content: data.value?.net?.ip ?? '',
},
{
type: 'SRV',
name: `_minecraft._tcp.${domain}`,
content: `0 10 ${data.value?.net?.port} ${domain}`,
},
]
})
const exportDnsRecords = () => {
const records = dnsRecords.value.reduce(
(acc, record) => {
const type = record.type;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(record);
return acc;
},
{} as Record<string, any[]>,
);
const records = dnsRecords.value.reduce(
(acc, record) => {
const type = record.type
if (!acc[type]) {
acc[type] = []
}
acc[type].push(record)
return acc
},
{} as Record<string, any[]>,
)
const text = Object.entries(records)
.map(([type, records]) => {
return `; ${type} Records\n${records.map((record) => `${record.name}. 1 IN ${record.type} ${record.content}${record.type === "SRV" ? "." : ""}`).join("\n")}\n`;
})
.join("\n");
const blob = new Blob([text], { type: "text/plain" });
const a = document.createElement("a");
a.href = window.URL.createObjectURL(blob);
a.download = `${userDomain.value}.txt`;
a.click();
a.remove();
};
const text = Object.entries(records)
.map(([type, records]) => {
return `; ${type} Records\n${records.map((record) => `${record.name}. 1 IN ${record.type} ${record.content}${record.type === 'SRV' ? '.' : ''}`).join('\n')}\n`
})
.join('\n')
const blob = new Blob([text], { type: 'text/plain' })
const a = document.createElement('a')
a.href = window.URL.createObjectURL(blob)
a.download = `${userDomain.value}.txt`
a.click()
a.remove()
}
const copyText = (text: string) => {
navigator.clipboard.writeText(text);
addNotification({
type: "success",
title: "Text copied",
text: `${text} has been copied to your clipboard`,
});
};
navigator.clipboard.writeText(text)
addNotification({
type: 'success',
title: 'Text copied',
text: `${text} has been copied to your clipboard`,
})
}
</script>

View File

@@ -1,129 +1,130 @@
<template>
<div class="h-full w-full">
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card flex flex-col gap-4">
<h1 class="m-0 text-lg font-bold text-contrast">Server preferences</h1>
<p class="m-0">Preferences apply per server and changes are only saved in your browser.</p>
<div
v-for="(prefConfig, key) in preferences"
:key="key"
class="flex items-center justify-between gap-2"
>
<label :for="`pref-${key}`" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<span class="text-lg font-bold text-contrast">{{ prefConfig.displayName }}</span>
<div
v-if="prefConfig.implemented === false"
class="hidden items-center gap-1 rounded-full bg-table-alternateRow p-1 px-1.5 text-xs font-semibold sm:flex"
>
Coming Soon
</div>
</div>
<span>{{ prefConfig.description }}</span>
</label>
<input
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="switch stylized-toggle flex-none"
type="checkbox"
:disabled="prefConfig.implemented === false"
/>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
/>
</div>
<div class="h-full w-full">
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card flex flex-col gap-4">
<h1 class="m-0 text-lg font-bold text-contrast">Server preferences</h1>
<p class="m-0">Preferences apply per server and changes are only saved in your browser.</p>
<div
v-for="(prefConfig, key) in preferences"
:key="key"
class="flex items-center justify-between gap-2"
>
<label :for="`pref-${key}`" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<span class="text-lg font-bold text-contrast">{{ prefConfig.displayName }}</span>
<div
v-if="prefConfig.implemented === false"
class="hidden items-center gap-1 rounded-full bg-table-alternateRow p-1 px-1.5 text-xs font-semibold sm:flex"
>
Coming Soon
</div>
</div>
<span>{{ prefConfig.description }}</span>
</label>
<input
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="switch stylized-toggle flex-none"
type="checkbox"
:disabled="prefConfig.implemented === false"
/>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from "@modrinth/ui";
import { useStorage } from "@vueuse/core";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { injectNotificationManager } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
const { addNotification } = injectNotificationManager();
const route = useNativeRoute();
const serverId = route.params.id as string;
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const route = useNativeRoute()
const serverId = route.params.id as string
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const preferences = {
ramAsNumber: {
displayName: "RAM as bytes",
description:
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
implemented: true,
},
hideSubdomainLabel: {
displayName: "Hide subdomain label",
description: "When enabled, the subdomain label will be hidden from the server header.",
implemented: true,
},
autoRestart: {
displayName: "Auto restart",
description: "When enabled, your server will automatically restart if it crashes.",
implemented: false,
},
powerDontAskAgain: {
displayName: "Power actions confirmation",
description: "When enabled, you will be prompted before stopping and restarting your server.",
implemented: true,
},
backupWhileRunning: {
displayName: "Create backups while running",
description: "When enabled, backups will be created even if the server is running.",
implemented: true,
},
} as const;
ramAsNumber: {
displayName: 'RAM as bytes',
description:
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
implemented: true,
},
hideSubdomainLabel: {
displayName: 'Hide subdomain label',
description: 'When enabled, the subdomain label will be hidden from the server header.',
implemented: true,
},
autoRestart: {
displayName: 'Auto restart',
description: 'When enabled, your server will automatically restart if it crashes.',
implemented: false,
},
powerDontAskAgain: {
displayName: 'Power actions confirmation',
description: 'When enabled, you will be prompted before stopping and restarting your server.',
implemented: true,
},
backupWhileRunning: {
displayName: 'Create backups while running',
description: 'When enabled, backups will be created even if the server is running.',
implemented: true,
},
} as const
type PreferenceKeys = keyof typeof preferences;
type PreferenceKeys = keyof typeof preferences
type UserPreferences = {
[K in PreferenceKeys]: boolean;
};
[K in PreferenceKeys]: boolean
}
const defaultPreferences: UserPreferences = {
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,
};
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,
}
const userPreferences = useStorage<UserPreferences>(
`pyro-server-${serverId}-preferences`,
defaultPreferences,
);
`pyro-server-${serverId}-preferences`,
defaultPreferences,
)
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)));
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)))
const hasUnsavedChanges = computed(() => {
return JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value);
});
return JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value)
})
const savePreferences = () => {
userPreferences.value = { ...newUserPreferences.value };
addNotification({
type: "success",
title: "Preferences saved",
text: "Your preferences have been saved.",
});
};
userPreferences.value = { ...newUserPreferences.value }
addNotification({
type: 'success',
title: 'Preferences saved',
text: 'Your preferences have been saved.',
})
}
const resetPreferences = () => {
newUserPreferences.value = { ...userPreferences.value };
};
newUserPreferences.value = { ...userPreferences.value }
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,332 +1,333 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="server.moduleErrors.fs"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.fs.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="server.moduleErrors.fs"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.fs.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-else-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
<div class="m-0">
Edit the Minecraft server properties file. If you're unsure about a specific property,
the
<NuxtLink
class="goto-link !inline-block"
to="https://minecraft.wiki/w/Server.properties"
external
>
Minecraft Wiki
</NuxtLink>
has more detailed information.
</div>
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
<div class="relative w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-server-properties"
v-model="searchInput"
class="w-full pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<div class="flex items-center">
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
<EyeIcon v-tooltip="overrides[index].info" />
</span>
</div>
<div
v-if="overrides[index] && overrides[index].type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<UiServersTeleportDropdownMenu
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="overrides[index].options || []"
:aria-labelledby="`property-label-${index}`"
placeholder="Select..."
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="switch stylized-toggle"
type="checkbox"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="typeof property === 'number'" class="mt-2 w-full sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model.number="liveProperties[index]"
type="number"
class="w-full border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<textarea
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="w-full resize-y rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
></textarea>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card flex h-full w-full items-center justify-center">
<p class="text-contrast">
The server properties file has not been generated yet. Start up your server to generate it.
</p>
</div>
<div
v-else-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
<div class="m-0">
Edit the Minecraft server properties file. If you're unsure about a specific property,
the
<NuxtLink
class="goto-link !inline-block"
to="https://minecraft.wiki/w/Server.properties"
external
>
Minecraft Wiki
</NuxtLink>
has more detailed information.
</div>
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
<div class="relative w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-server-properties"
v-model="searchInput"
class="w-full pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<div class="flex items-center">
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
<EyeIcon v-tooltip="overrides[index].info" />
</span>
</div>
<div
v-if="overrides[index] && overrides[index].type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<UiServersTeleportDropdownMenu
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="overrides[index].options || []"
:aria-labelledby="`property-label-${index}`"
placeholder="Select..."
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="switch stylized-toggle"
type="checkbox"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="typeof property === 'number'" class="mt-2 w-full sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model.number="liveProperties[index]"
type="number"
class="w-full border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<textarea
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="w-full resize-y rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
></textarea>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card flex h-full w-full items-center justify-center">
<p class="text-contrast">
The server properties file has not been generated yet. Start up your server to generate it.
</p>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
restart
:save="saveProperties"
:reset="resetProperties"
/>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
restart
:save="saveProperties"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, IssuesIcon, SearchIcon } from "@modrinth/assets";
import { injectNotificationManager } from "@modrinth/ui";
import Fuse from "fuse.js";
import { computed, inject, ref, watch } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const tags = useTags();
const tags = useTags()
const isUpdating = ref(false);
const isUpdating = ref(false)
const searchInput = ref("");
const searchInput = ref('')
const data = computed(() => props.server.general);
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
await modulesLoaded;
const rawProps = await props.server.fs?.downloadFile("server.properties");
if (!rawProps) return null;
const data = computed(() => props.server.general)
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
await modulesLoaded
const rawProps = await props.server.fs?.downloadFile('server.properties')
if (!rawProps) return null
const properties: Record<string, any> = {};
const lines = rawProps.split("\n");
const properties: Record<string, any> = {}
const lines = rawProps.split('\n')
for (const line of lines) {
if (line.startsWith("#") || !line.includes("=")) continue;
const [key, ...valueParts] = line.split("=");
let value = valueParts.join("=");
for (const line of lines) {
if (line.startsWith('#') || !line.includes('=')) continue
const [key, ...valueParts] = line.split('=')
let value = valueParts.join('=')
if (value.toLowerCase() === "true" || value.toLowerCase() === "false") {
value = value.toLowerCase() === "true";
} else if (!isNaN(value as any) && value !== "") {
value = Number(value);
}
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
value = value.toLowerCase() === 'true'
} else if (!isNaN(value as any) && value !== '') {
value = Number(value)
}
properties[key.trim()] = value;
}
properties[key.trim()] = value
}
return properties;
});
return properties
})
const liveProperties = ref<Record<string, any>>({});
const originalProperties = ref<Record<string, any>>({});
const liveProperties = ref<Record<string, any>>({})
const originalProperties = ref<Record<string, any>>({})
watch(
propsData,
(newPropsData) => {
if (newPropsData) {
liveProperties.value = JSON.parse(JSON.stringify(newPropsData));
originalProperties.value = JSON.parse(JSON.stringify(newPropsData));
}
},
{ immediate: true },
);
propsData,
(newPropsData) => {
if (newPropsData) {
liveProperties.value = JSON.parse(JSON.stringify(newPropsData))
originalProperties.value = JSON.parse(JSON.stringify(newPropsData))
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(() => {
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
);
});
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
)
})
const getDifficultyOptions = () => {
const pre113Versions = tags.value.gameVersions
.filter((v) => {
const versionNumbers = v.version.split(".").map(Number);
return versionNumbers[0] === 1 && versionNumbers[1] < 13;
})
.map((v) => v.version);
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
return ["0", "1", "2", "3"];
} else {
return ["peaceful", "easy", "normal", "hard"];
}
};
const pre113Versions = tags.value.gameVersions
.filter((v) => {
const versionNumbers = v.version.split('.').map(Number)
return versionNumbers[0] === 1 && versionNumbers[1] < 13
})
.map((v) => v.version)
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
return ['0', '1', '2', '3']
} else {
return ['peaceful', 'easy', 'normal', 'hard']
}
}
const overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
difficulty: {
type: "dropdown",
options: getDifficultyOptions(),
},
gamemode: {
type: "dropdown",
options: ["survival", "creative", "adventure", "spectator"],
},
};
difficulty: {
type: 'dropdown',
options: getDifficultyOptions(),
},
gamemode: {
type: 'dropdown',
options: ['survival', 'creative', 'adventure', 'spectator'],
},
}
const fuse = computed(() => {
if (!liveProperties.value) return null;
if (!liveProperties.value) return null
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}));
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(propertiesToFuse, {
keys: ["key", "value"],
threshold: 0.2,
});
});
return new Fuse(propertiesToFuse, {
keys: ['key', 'value'],
threshold: 0.2,
})
})
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) {
return liveProperties.value;
}
if (!searchInput.value?.trim()) {
return liveProperties.value
}
const results = fuse.value?.search(searchInput.value) ?? [];
const results = fuse.value?.search(searchInput.value) ?? []
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]));
});
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
const constructServerProperties = (): string => {
const properties = liveProperties.value;
const properties = liveProperties.value
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
for (const [key, value] of Object.entries(properties)) {
if (typeof value === "object") {
fileContent += `${key}=${JSON.stringify(value)}\n`;
} else if (typeof value === "boolean") {
fileContent += `${key}=${value ? "true" : "false"}\n`;
} else {
fileContent += `${key}=${value}\n`;
}
}
for (const [key, value] of Object.entries(properties)) {
if (typeof value === 'object') {
fileContent += `${key}=${JSON.stringify(value)}\n`
} else if (typeof value === 'boolean') {
fileContent += `${key}=${value ? 'true' : 'false'}\n`
} else {
fileContent += `${key}=${value}\n`
}
}
return fileContent;
};
return fileContent
}
const saveProperties = async () => {
try {
isUpdating.value = true;
await props.server.fs?.updateFile("server.properties", constructServerProperties());
await new Promise((resolve) => setTimeout(resolve, 500));
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value));
await props.server.refresh();
addNotification({
type: "success",
title: "Server properties updated",
text: "Your server properties were successfully changed.",
});
} catch (error) {
console.error("Error updating server properties:", error);
addNotification({
type: "error",
title: "Failed to update server properties",
text: "An error occurred while attempting to update your server properties.",
});
} finally {
isUpdating.value = false;
}
};
try {
isUpdating.value = true
await props.server.fs?.updateFile('server.properties', constructServerProperties())
await new Promise((resolve) => setTimeout(resolve, 500))
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
await props.server.refresh()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
})
} catch (error) {
console.error('Error updating server properties:', error)
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: 'An error occurred while attempting to update your server properties.',
})
} finally {
isUpdating.value = false
}
}
const resetProperties = async () => {
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value));
await new Promise((resolve) => setTimeout(resolve, 200));
};
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value))
await new Promise((resolve) => setTimeout(resolve, 200))
}
const formatPropertyName = (propertyName: string): string => {
return propertyName
.split(/[-.]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
return propertyName
.split(/[-.]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const isComplexProperty = (property: any): boolean => {
return (
typeof property === "object" ||
(typeof property === "string" &&
(property.includes(",") ||
property.includes("{") ||
property.includes("}") ||
property.includes("[") ||
property.includes("]") ||
property.length > 30))
);
};
return (
typeof property === 'object' ||
(typeof property === 'string' &&
(property.includes(',') ||
property.includes('{') ||
property.includes('}') ||
property.includes('[') ||
property.includes(']') ||
property.length > 30))
)
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,234 +1,235 @@
<template>
<div class="relative h-full w-full">
<div
v-if="server.moduleErrors.startup"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.startup.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
<div
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
These settings are for advanced users. Changing them can break your server.
</div>
<div class="relative h-full w-full">
<div
v-if="server.moduleErrors.startup"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.startup.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
<div
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
These settings are for advanced users. Changing them can break your server.
</div>
<div class="gap-2">
<div class="card flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label for="startup-command-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Startup command</span>
<span> The command that runs when your server is started. </span>
</label>
<ButtonStyled>
<button
:disabled="invocation === originalInvocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Restore default command
</button>
</ButtonStyled>
</div>
<textarea
id="startup-command-field"
v-model="invocation"
class="min-h-[270px] w-full resize-y font-[family-name:var(--mono-font)]"
/>
</div>
<div class="gap-2">
<div class="card flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label for="startup-command-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Startup command</span>
<span> The command that runs when your server is started. </span>
</label>
<ButtonStyled>
<button
:disabled="invocation === originalInvocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Restore default command
</button>
</ButtonStyled>
</div>
<textarea
id="startup-command-field"
v-model="invocation"
class="min-h-[270px] w-full resize-y font-[family-name:var(--mono-font)]"
/>
</div>
<div class="card flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Java version</span>
<span>
The version of Java that your server will run on. By default, only the Java versions
compatible with this version of Minecraft are shown. Some mods may require a
different Java version to work properly.
</span>
</div>
<div class="flex items-center gap-2">
<input
id="show-all-versions"
v-model="showAllVersions"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</div>
<UiServersTeleportDropdownMenu
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="displayedJavaVersions"
placeholder="Java Version"
/>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Runtime</span>
<span> The Java runtime your server will use. </span>
</div>
<UiServersTeleportDropdownMenu
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM']"
placeholder="Runtime"
/>
</div>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:reset="resetStartup"
/>
</div>
<div class="card flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Java version</span>
<span>
The version of Java that your server will run on. By default, only the Java versions
compatible with this version of Minecraft are shown. Some mods may require a
different Java version to work properly.
</span>
</div>
<div class="flex items-center gap-2">
<input
id="show-all-versions"
v-model="showAllVersions"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</div>
<UiServersTeleportDropdownMenu
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="displayedJavaVersions"
placeholder="Java Version"
/>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Runtime</span>
<span> The Java runtime your server will use. </span>
</div>
<UiServersTeleportDropdownMenu
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM']"
placeholder="Runtime"
/>
</div>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager } from "@modrinth/ui";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
await props.server.startup.fetch();
await props.server.startup.fetch()
const data = computed(() => props.server.general);
const showAllVersions = ref(false);
const data = computed(() => props.server.general)
const showAllVersions = ref(false)
const jdkVersionMap = [
{ value: "lts8", label: "Java 8" },
{ value: "lts11", label: "Java 11" },
{ value: "lts17", label: "Java 17" },
{ value: "lts21", label: "Java 21" },
];
{ value: 'lts8', label: 'Java 8' },
{ value: 'lts11', label: 'Java 11' },
{ value: 'lts17', label: 'Java 17' },
{ value: 'lts21', label: 'Java 21' },
]
const jdkBuildMap = [
{ value: "corretto", label: "Corretto" },
{ value: "temurin", label: "Temurin" },
{ value: "graal", label: "GraalVM" },
];
{ value: 'corretto', label: 'Corretto' },
{ value: 'temurin', label: 'Temurin' },
{ value: 'graal', label: 'GraalVM' },
]
const invocation = ref(props.server.startup.invocation);
const invocation = ref(props.server.startup.invocation)
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
);
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
)
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label)
const originalInvocation = ref(invocation.value);
const originalJdkVersion = ref(jdkVersion.value);
const originalJdkBuild = ref(jdkBuild.value);
const originalInvocation = ref(invocation.value)
const originalJdkVersion = ref(jdkVersion.value)
const originalJdkBuild = ref(jdkBuild.value)
const hasUnsavedChanges = computed(
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
);
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
)
const isUpdating = ref(false);
const isUpdating = ref(false)
const compatibleJavaVersions = computed(() => {
const mcVersion = data.value?.mc_version ?? "";
if (!mcVersion) return jdkVersionMap.map((v) => v.label);
const mcVersion = data.value?.mc_version ?? ''
if (!mcVersion) return jdkVersionMap.map((v) => v.label)
const [major, minor] = mcVersion.split(".").map(Number);
const [major, minor] = mcVersion.split('.').map(Number)
if (major >= 1) {
if (minor >= 20) return ["Java 21"];
if (minor >= 18) return ["Java 17", "Java 21"];
if (minor >= 17) return ["Java 16", "Java 17", "Java 21"];
if (minor >= 12) return ["Java 8", "Java 11", "Java 17", "Java 21"];
if (minor >= 6) return ["Java 8", "Java 11"];
}
if (major >= 1) {
if (minor >= 20) return ['Java 21']
if (minor >= 18) return ['Java 17', 'Java 21']
if (minor >= 17) return ['Java 16', 'Java 17', 'Java 21']
if (minor >= 12) return ['Java 8', 'Java 11', 'Java 17', 'Java 21']
if (minor >= 6) return ['Java 8', 'Java 11']
}
return ["Java 8"];
});
return ['Java 8']
})
const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
});
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value
})
async function saveStartup() {
try {
isUpdating.value = true;
const invocationValue = invocation.value ?? "";
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value;
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value;
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any);
try {
isUpdating.value = true
const invocationValue = invocation.value ?? ''
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any)
await new Promise((resolve) => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10))
await props.server.refresh(["startup"]);
await props.server.refresh(['startup'])
if (props.server.startup) {
invocation.value = props.server.startup.invocation;
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || "";
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || "";
}
if (props.server.startup) {
invocation.value = props.server.startup.invocation
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || ''
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || ''
}
addNotification({
type: "success",
title: "Server settings updated",
text: "Your server settings were successfully changed.",
});
} catch (error) {
console.error(error);
addNotification({
type: "error",
title: "Failed to update server arguments",
text: "Please try again later.",
});
} finally {
isUpdating.value = false;
}
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server arguments',
text: 'Please try again later.',
})
} finally {
isUpdating.value = false
}
}
function resetStartup() {
invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value;
invocation.value = originalInvocation.value
jdkVersion.value = originalJdkVersion.value
jdkBuild.value = originalJdkBuild.value
}
function resetToDefault() {
invocation.value = originalInvocation.value ?? "";
invocation.value = originalInvocation.value ?? ''
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,210 +1,211 @@
<template>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="hasError || fetchError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<HammerIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
</div>
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
<li>
Our systems automatically alert our team when there's an issue. We are already working
on getting them back online.
</li>
<li>
If you recently purchased your Modrinth Server, it is currently in a queue and will
appear here as soon as it's ready. <br />
<span class="font-medium text-contrast"
>Do not attempt to purchase a new server.</span
>
</li>
<li>
If you require personalized support regarding the status of your server, please
contact Modrinth Support.
</li>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="hasError || fetchError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<HammerIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
</div>
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
<li>
Our systems automatically alert our team when there's an issue. We are already working
on getting them back online.
</li>
<li>
If you recently purchased your Modrinth Server, it is currently in a queue and will
appear here as soon as it's ready. <br />
<span class="font-medium text-contrast"
>Do not attempt to purchase a new server.</span
>
</li>
<li>
If you require personalized support regarding the status of your server, please
contact Modrinth Support.
</li>
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
/>
</li>
</ul>
</div>
<ButtonStyled size="large" type="standard" color="brand">
<a class="mt-6 !w-full" href="https://support.modrinth.com">Contact Modrinth Support</a>
</ButtonStyled>
<ButtonStyled size="large" @click="() => reloadNuxtApp()">
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
/>
</li>
</ul>
</div>
<ButtonStyled size="large" type="standard" color="brand">
<a class="mt-6 !w-full" href="https://support.modrinth.com">Contact Modrinth Support</a>
</ButtonStyled>
<ButtonStyled size="large" @click="() => reloadNuxtApp()">
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<LazyUiServersServerManageEmptyState
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
/>
<LazyUiServersServerManageEmptyState
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
/>
<template v-else>
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div class="relative w-full text-sm md:w-72">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
/>
</div>
<ButtonStyled type="standard">
<NuxtLink
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
:to="{ path: '/servers', hash: '#plan' }"
>
<PlusIcon class="size-4" />
New server
</NuxtLink>
</ButtonStyled>
</div>
</div>
<template v-else>
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div class="relative w-full text-sm md:w-72">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
/>
</div>
<ButtonStyled type="standard">
<NuxtLink
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
:to="{ path: '/servers', hash: '#plan' }"
>
<PlusIcon class="size-4" />
New server
</NuxtLink>
</ButtonStyled>
</div>
</div>
<ul
v-if="filteredData.length > 0 || isPollingForNewServers"
class="m-0 flex flex-col gap-4 p-0"
>
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
v-bind="server"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
<div v-else class="flex h-full items-center justify-center">
<p class="text-contrast">No servers found.</p>
</div>
</template>
</div>
<ul
v-if="filteredData.length > 0 || isPollingForNewServers"
class="m-0 flex flex-col gap-4 p-0"
>
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
v-bind="server"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
<div v-else class="flex h-full items-center justify-center">
<p class="text-contrast">No servers found.</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import Fuse from "fuse.js";
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
import { ButtonStyled, CopyCode } from "@modrinth/ui";
import type { Server, ModrinthServersFetchError } from "@modrinth/utils";
import { reloadNuxtApp } from "#app";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode } from '@modrinth/ui'
import type { ModrinthServersFetchError, Server } from '@modrinth/utils'
import Fuse from 'fuse.js'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { reloadNuxtApp } from '#app'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
useHead({
title: "Servers - Modrinth",
});
title: 'Servers - Modrinth',
})
interface ServerResponse {
servers: Server[];
servers: Server[]
}
const router = useRouter();
const route = useRoute();
const hasError = ref(false);
const isPollingForNewServers = ref(false);
const router = useRouter()
const route = useRoute()
const hasError = ref(false)
const isPollingForNewServers = ref(false)
const {
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>("ServerList", () =>
useServersFetch<ServerResponse>("servers"),
);
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>('ServerList', () =>
useServersFetch<ServerResponse>('servers'),
)
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response;
});
hasError.value = !!error || !response
})
const serverList = computed(() => {
if (!serverResponse.value) return [];
return serverResponse.value.servers;
});
if (!serverResponse.value) return []
return serverResponse.value.servers
})
const searchInput = ref("");
const searchInput = ref('')
const fuse = computed(() => {
if (serverList.value.length === 0) return null;
return new Fuse(serverList.value, {
keys: ["name", "loader", "mc_version", "game", "state"],
includeScore: true,
threshold: 0.4,
});
});
if (serverList.value.length === 0) return null
return new Fuse(serverList.value, {
keys: ['name', 'loader', 'mc_version', 'game', 'state'],
includeScore: true,
threshold: 0.4,
})
})
function introToTop(array: Server[]): Server[] {
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro);
});
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro)
})
}
const filteredData = computed(() => {
if (!searchInput.value.trim()) {
return introToTop(serverList.value);
}
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: [];
});
if (!searchInput.value.trim()) {
return introToTop(serverList.value)
}
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: []
})
const previousServerList = ref<Server[]>([]);
const refreshCount = ref(0);
const previousServerList = ref<Server[]>([])
const refreshCount = ref(0)
const checkForNewServers = async () => {
await refresh();
refreshCount.value += 1;
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
router.replace({ query: {} });
} else if (refreshCount.value >= 5) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
}
};
await refresh()
refreshCount.value += 1
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
isPollingForNewServers.value = false
clearInterval(intervalId)
router.replace({ query: {} })
} else if (refreshCount.value >= 5) {
isPollingForNewServers.value = false
clearInterval(intervalId)
}
}
let intervalId: ReturnType<typeof setInterval> | undefined;
let intervalId: ReturnType<typeof setInterval> | undefined
onMounted(() => {
if (route.query.redirect_status === "succeeded") {
isPollingForNewServers.value = true;
previousServerList.value = [...serverList.value];
intervalId = setInterval(checkForNewServers, 5000);
}
});
if (route.query.redirect_status === 'succeeded') {
isPollingForNewServers.value = true
previousServerList.value = [...serverList.value]
intervalId = setInterval(checkForNewServers, 5000)
}
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
if (intervalId) {
clearInterval(intervalId)
}
})
</script>

View File

@@ -1,102 +1,103 @@
<template>
<div>
<div class="normal-page no-sidebar">
<h1>{{ formatMessage(commonMessages.settingsLabel) }}</h1>
</div>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<NavStack>
<h3>Display</h3>
<NavStackItem
link="/settings"
:label="formatMessage(commonSettingsMessages.appearance)"
>
<PaintbrushIcon />
</NavStackItem>
<NavStackItem
v-if="isStaging"
link="/settings/language"
:label="formatMessage(commonSettingsMessages.language)"
>
<LanguagesIcon />
</NavStackItem>
<template v-if="auth.user">
<h3>Account</h3>
<NavStackItem
link="/settings/profile"
:label="formatMessage(commonSettingsMessages.profile)"
>
<UserIcon />
</NavStackItem>
<NavStackItem
link="/settings/account"
:label="formatMessage(commonSettingsMessages.account)"
>
<ShieldIcon />
</NavStackItem>
<NavStackItem
link="/settings/authorizations"
:label="formatMessage(commonSettingsMessages.authorizedApps)"
>
<GridIcon />
</NavStackItem>
<NavStackItem
link="/settings/sessions"
:label="formatMessage(commonSettingsMessages.sessions)"
>
<MonitorSmartphoneIcon />
</NavStackItem>
<NavStackItem
link="/settings/billing"
:label="formatMessage(commonSettingsMessages.billing)"
>
<CardIcon />
</NavStackItem>
</template>
<template v-if="auth.user">
<h3>Developer</h3>
<NavStackItem
link="/settings/pats"
:label="formatMessage(commonSettingsMessages.pats)"
>
<KeyIcon />
</NavStackItem>
<NavStackItem
link="/settings/applications"
:label="formatMessage(commonSettingsMessages.applications)"
>
<ServerIcon />
</NavStackItem>
</template>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage :route="route" />
</div>
</div>
</div>
<div>
<div class="normal-page no-sidebar">
<h1>{{ formatMessage(commonMessages.settingsLabel) }}</h1>
</div>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<NavStack>
<h3>Display</h3>
<NavStackItem
link="/settings"
:label="formatMessage(commonSettingsMessages.appearance)"
>
<PaintbrushIcon />
</NavStackItem>
<NavStackItem
v-if="isStaging"
link="/settings/language"
:label="formatMessage(commonSettingsMessages.language)"
>
<LanguagesIcon />
</NavStackItem>
<template v-if="auth.user">
<h3>Account</h3>
<NavStackItem
link="/settings/profile"
:label="formatMessage(commonSettingsMessages.profile)"
>
<UserIcon />
</NavStackItem>
<NavStackItem
link="/settings/account"
:label="formatMessage(commonSettingsMessages.account)"
>
<ShieldIcon />
</NavStackItem>
<NavStackItem
link="/settings/authorizations"
:label="formatMessage(commonSettingsMessages.authorizedApps)"
>
<GridIcon />
</NavStackItem>
<NavStackItem
link="/settings/sessions"
:label="formatMessage(commonSettingsMessages.sessions)"
>
<MonitorSmartphoneIcon />
</NavStackItem>
<NavStackItem
link="/settings/billing"
:label="formatMessage(commonSettingsMessages.billing)"
>
<CardIcon />
</NavStackItem>
</template>
<template v-if="auth.user">
<h3>Developer</h3>
<NavStackItem
link="/settings/pats"
:label="formatMessage(commonSettingsMessages.pats)"
>
<KeyIcon />
</NavStackItem>
<NavStackItem
link="/settings/applications"
:label="formatMessage(commonSettingsMessages.applications)"
>
<ServerIcon />
</NavStackItem>
</template>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage :route="route" />
</div>
</div>
</div>
</template>
<script setup>
import {
ServerIcon,
GridIcon,
PaintbrushIcon,
UserIcon,
ShieldIcon,
KeyIcon,
LanguagesIcon,
CardIcon,
MonitorSmartphoneIcon,
} from "@modrinth/assets";
import { commonMessages, commonSettingsMessages } from "@modrinth/ui";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
CardIcon,
GridIcon,
KeyIcon,
LanguagesIcon,
MonitorSmartphoneIcon,
PaintbrushIcon,
ServerIcon,
ShieldIcon,
UserIcon,
} from '@modrinth/assets'
import { commonMessages, commonSettingsMessages } from '@modrinth/ui'
const { formatMessage } = useVIntl();
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const route = useNativeRoute();
const auth = await useAuth();
const isStaging = useRuntimeConfig().public.siteUrl !== "https://modrinth.com";
const { formatMessage } = useVIntl()
const route = useNativeRoute()
const auth = await useAuth()
const isStaging = useRuntimeConfig().public.siteUrl !== 'https://modrinth.com'
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,250 +1,251 @@
<template>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to revoke this application?"
description="This will revoke the application's access to your account. You can always re-authorize it later."
proceed-label="Revoke"
@proceed="revokeApp(revokingId)"
/>
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.authorizedApps) }}</h2>
<p>
When you authorize an application with your Modrinth account, you grant it access to your
account. You can manage and review access to your account here at any time.
</p>
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
You have not authorized any applications.
</div>
<div
v-for="authorization in appInfoLookup"
:key="authorization.id"
class="universal-card recessed token mt-4"
>
<div class="token-content">
<div>
<div class="icon-name">
<Avatar :src="authorization.app.icon_url" />
<div>
<h2 class="token-title">
{{ authorization.app.name }}
</h2>
<div>
by
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
authorization.owner.username
}}</nuxt-link>
<template v-if="authorization.app.url">
<span> </span>
<nuxt-link class="text-link" :to="authorization.app.url">
{{ authorization.app.url }}
</nuxt-link>
</template>
</div>
</div>
</div>
</div>
<div>
<template v-if="authorization.app.description">
<label for="app-description">
<span class="label__title"> About this app </span>
</label>
<div id="app-description">{{ authorization.app.description }}</div>
</template>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to revoke this application?"
description="This will revoke the application's access to your account. You can always re-authorize it later."
proceed-label="Revoke"
@proceed="revokeApp(revokingId)"
/>
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.authorizedApps) }}</h2>
<p>
When you authorize an application with your Modrinth account, you grant it access to your
account. You can manage and review access to your account here at any time.
</p>
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
You have not authorized any applications.
</div>
<div
v-for="authorization in appInfoLookup"
:key="authorization.id"
class="universal-card recessed token mt-4"
>
<div class="token-content">
<div>
<div class="icon-name">
<Avatar :src="authorization.app.icon_url" />
<div>
<h2 class="token-title">
{{ authorization.app.name }}
</h2>
<div>
by
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
authorization.owner.username
}}</nuxt-link>
<template v-if="authorization.app.url">
<span> </span>
<nuxt-link class="text-link" :to="authorization.app.url">
{{ authorization.app.url }}
</nuxt-link>
</template>
</div>
</div>
</div>
</div>
<div>
<template v-if="authorization.app.description">
<label for="app-description">
<span class="label__title"> About this app </span>
</label>
<div id="app-description">{{ authorization.app.description }}</div>
</template>
<label for="app-scope-list">
<span class="label__title">Scopes</span>
</label>
<div class="scope-list">
<div
v-for="scope in scopesToDefinitions(authorization.scopes)"
:key="scope"
class="scope-list-item"
>
<div class="scope-list-item-icon">
<CheckIcon />
</div>
{{ scope }}
</div>
</div>
</div>
</div>
<label for="app-scope-list">
<span class="label__title">Scopes</span>
</label>
<div class="scope-list">
<div
v-for="scope in scopesToDefinitions(authorization.scopes)"
:key="scope"
class="scope-list-item"
>
<div class="scope-list-item-icon">
<CheckIcon />
</div>
{{ scope }}
</div>
</div>
</div>
</div>
<div class="input-group">
<Button
color="danger"
icon-only
@click="
() => {
revokingId = authorization.app_id;
$refs.modal_confirm.show();
}
"
>
<TrashIcon />
Revoke
</Button>
</div>
</div>
</div>
<div class="input-group">
<Button
color="danger"
icon-only
@click="
() => {
revokingId = authorization.app_id
$refs.modal_confirm.show()
}
"
>
<TrashIcon />
Revoke
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { CheckIcon, TrashIcon } from "@modrinth/assets";
import { CheckIcon, TrashIcon } from '@modrinth/assets'
import {
Avatar,
Button,
commonSettingsMessages,
ConfirmModal,
injectNotificationManager,
} from "@modrinth/ui";
import { useScopes } from "~/composables/auth/scopes.ts";
Avatar,
Button,
commonSettingsMessages,
ConfirmModal,
injectNotificationManager,
} from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
import { useScopes } from '~/composables/auth/scopes.ts'
const { scopesToDefinitions } = useScopes();
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const revokingId = ref(null);
const { scopesToDefinitions } = useScopes()
const revokingId = ref(null)
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
useHead({
title: "Authorizations - Modrinth",
});
title: 'Authorizations - Modrinth',
})
const { data: usersApps, refresh } = await useAsyncData("userAuthorizations", () =>
useBaseFetch(`oauth/authorizations`, {
internal: true,
}),
);
const { data: usersApps, refresh } = await useAsyncData('userAuthorizations', () =>
useBaseFetch(`oauth/authorizations`, {
internal: true,
}),
)
const { data: appInformation } = await useAsyncData(
"appInfo",
() =>
useBaseFetch("oauth/apps", {
internal: true,
query: {
ids: usersApps.value.map((c) => c.app_id).join(","),
},
}),
{
watch: usersApps,
},
);
'appInfo',
() =>
useBaseFetch('oauth/apps', {
internal: true,
query: {
ids: usersApps.value.map((c) => c.app_id).join(','),
},
}),
{
watch: usersApps,
},
)
const { data: appCreatorsInformation } = await useAsyncData(
"appCreatorsInfo",
() =>
useBaseFetch("users", {
query: {
ids: JSON.stringify(appInformation.value.map((c) => c.created_by)),
},
}),
{
watch: appInformation,
},
);
'appCreatorsInfo',
() =>
useBaseFetch('users', {
query: {
ids: JSON.stringify(appInformation.value.map((c) => c.created_by)),
},
}),
{
watch: appInformation,
},
)
const appInfoLookup = computed(() => {
return usersApps.value.map((app) => {
const info = appInformation.value.find((c) => c.id === app.app_id);
const owner = appCreatorsInformation.value.find((c) => c.id === info.created_by);
return {
...app,
app: info || null,
owner: owner || null,
};
});
});
return usersApps.value.map((app) => {
const info = appInformation.value.find((c) => c.id === app.app_id)
const owner = appCreatorsInformation.value.find((c) => c.id === info.created_by)
return {
...app,
app: info || null,
owner: owner || null,
}
})
})
async function revokeApp(id) {
try {
await useBaseFetch(`oauth/authorizations`, {
internal: true,
method: "DELETE",
query: {
client_id: id,
},
});
revokingId.value = null;
await refresh();
} catch (err) {
addNotification({
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
try {
await useBaseFetch(`oauth/authorizations`, {
internal: true,
method: 'DELETE',
query: {
client_id: id,
},
})
revokingId.value = null
await refresh()
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
</script>
<style lang="scss" scoped>
.input-group {
// Overrides for omorphia compat
> * {
padding: var(--gap-sm) var(--gap-lg) !important;
}
// Overrides for omorphia compat
> * {
padding: var(--gap-sm) var(--gap-lg) !important;
}
}
.scope-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--gap-sm);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--gap-sm);
.scope-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.25rem;
background-color: var(--color-gray-200);
color: var(--color-gray-700);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
.scope-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.25rem;
background-color: var(--color-gray-200);
color: var(--color-gray-700);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
// avoid breaking text or overflowing
white-space: nowrap;
overflow: hidden;
}
// avoid breaking text or overflowing
white-space: nowrap;
overflow: hidden;
}
.scope-list-item-icon {
width: 1.25rem;
height: 1.25rem;
flex: 0 0 auto;
.scope-list-item-icon {
width: 1.25rem;
height: 1.25rem;
flex: 0 0 auto;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-green);
color: var(--color-raised-bg);
}
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-green);
color: var(--color-raised-bg);
}
}
.icon-name {
display: flex;
align-items: flex-start;
gap: var(--gap-lg);
padding-bottom: var(--gap-sm);
display: flex;
align-items: flex-start;
gap: var(--gap-lg);
padding-bottom: var(--gap-sm);
}
.token-content {
width: 100%;
width: 100%;
.token-title {
margin-bottom: var(--spacing-card-xs);
}
.token-title {
margin-bottom: var(--spacing-card-xs);
}
}
.token {
display: flex;
flex-direction: column;
gap: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: flex-start;
}
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: flex-start;
}
}
</style>

View File

@@ -1,70 +1,71 @@
<template>
<div>
<section class="card">
<Breadcrumbs
current-title="Past charges"
:link-stack="[{ href: '/settings/billing', label: 'Billing and subscriptions' }]"
/>
<h2>Past charges</h2>
<p>All of your past charges to your Modrinth account will be listed here:</p>
<div
v-for="charge in charges"
:key="charge.id"
class="universal-card recessed flex items-center justify-between gap-4"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1">
<span class="font-bold text-primary">
<template v-if="charge.product.metadata.type === 'midas'"> Modrinth Plus </template>
<template v-else-if="charge.product.metadata.type === 'pyro'">
Modrinth Servers
</template>
<template v-else> Unknown product </template>
<template v-if="charge.subscription_interval">
{{ charge.subscription_interval }}
</template>
</span>
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
</div>
<div class="flex items-center gap-1">
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
</div>
</div>
</div>
</section>
</div>
<div>
<section class="card">
<Breadcrumbs
current-title="Past charges"
:link-stack="[{ href: '/settings/billing', label: 'Billing and subscriptions' }]"
/>
<h2>Past charges</h2>
<p>All of your past charges to your Modrinth account will be listed here:</p>
<div
v-for="charge in charges"
:key="charge.id"
class="universal-card recessed flex items-center justify-between gap-4"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1">
<span class="font-bold text-primary">
<template v-if="charge.product.metadata.type === 'midas'"> Modrinth Plus </template>
<template v-else-if="charge.product.metadata.type === 'pyro'">
Modrinth Servers
</template>
<template v-else> Unknown product </template>
<template v-if="charge.subscription_interval">
{{ charge.subscription_interval }}
</template>
</span>
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
</div>
<div class="flex items-center gap-1">
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
{{ $dayjs(charge.due).format('YYYY-MM-DD') }}
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { Breadcrumbs, Badge } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils";
import { products } from "~/generated/state.json";
import { Badge, Breadcrumbs } from '@modrinth/ui'
import { formatPrice } from '@modrinth/utils'
import { products } from '~/generated/state.json'
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
const vintl = useVIntl();
const vintl = useVIntl()
const { data: charges } = await useAsyncData(
"billing/payments",
() => useBaseFetch("billing/payments", { internal: true }),
{
transform: (charges) => {
return charges
.filter((charge) => charge.status !== "open" && charge.status !== "cancelled")
.map((charge) => {
const product = products.find((product) =>
product.prices.some((price) => price.id === charge.price_id),
);
'billing/payments',
() => useBaseFetch('billing/payments', { internal: true }),
{
transform: (charges) => {
return charges
.filter((charge) => charge.status !== 'open' && charge.status !== 'cancelled')
.map((charge) => {
const product = products.find((product) =>
product.prices.some((price) => price.id === charge.price_id),
)
charge.product = product;
charge.product = product
return charge;
});
},
},
);
return charge
})
},
},
)
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,489 +1,496 @@
<template>
<div>
<MessageBanner v-if="flags.developerMode" message-type="warning" class="developer-message">
<CodeIcon class="inline-flex" />
<IntlFormatted :message-id="developerModeBanner.description">
<template #strong="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
<Button :action="() => disableDeveloperMode()">
{{ formatMessage(developerModeBanner.deactivate) }}
</Button>
</MessageBanner>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(colorTheme.title) }}</h2>
<p>{{ formatMessage(colorTheme.description) }}</p>
<ThemeSelector
:update-color-theme="updateColorTheme"
:current-theme="theme.preferred"
:theme-options="themeOptions"
:system-theme-color="systemTheme"
/>
</section>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(projectListLayouts.title) }}</h2>
<p class="mb-4">{{ formatMessage(projectListLayouts.description) }}</p>
<div class="project-lists">
<div v-for="projectType in listTypes" :key="projectType.id + '-project-list-layouts'">
<div class="label">
<div class="label__title">
{{
projectListLayouts[projectType.id]
? formatMessage(projectListLayouts[projectType.id])
: projectType.id
}}
</div>
</div>
<div class="project-list-layouts">
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'list' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'list')"
>
<div class="preview">
<div class="layout-list-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Rows
</div>
</button>
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'grid' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
>
<div class="preview">
<div class="layout-grid-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Grid
</div>
</button>
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
>
<div class="preview">
<div class="layout-gallery-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Gallery
</div>
</button>
</div>
</div>
</div>
</section>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(toggleFeatures.title) }}</h2>
<p class="mb-4">{{ formatMessage(toggleFeatures.description) }}</p>
<div class="adjacent-input small">
<label for="advanced-rendering">
<span class="label__title">
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
</span>
</label>
<input
id="advanced-rendering"
v-model="cosmetics.advancedRendering"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="external-links-new-tab">
<span class="label__title">
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
</span>
</label>
<input
id="external-links-new-tab"
v-model="cosmetics.externalLinksNewTab"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div v-if="false" class="adjacent-input small">
<label for="modrinth-app-promos">
<span class="label__title">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
</span>
</label>
<input
id="modrinth-app-promos"
v-model="cosmetics.hideModrinthAppPromos"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarDescription) }}
</span>
</label>
<input
id="search-layout-toggle"
v-model="cosmetics.rightSearchLayout"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarDescription) }}
</span>
</label>
<input
id="project-layout-toggle"
v-model="cosmetics.leftContentLayout"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
</section>
</div>
<div>
<MessageBanner v-if="flags.developerMode" message-type="warning" class="developer-message">
<CodeIcon class="inline-flex" />
<IntlFormatted :message-id="developerModeBanner.description">
<template #strong="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
<Button :action="() => disableDeveloperMode()">
{{ formatMessage(developerModeBanner.deactivate) }}
</Button>
</MessageBanner>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(colorTheme.title) }}</h2>
<p>{{ formatMessage(colorTheme.description) }}</p>
<ThemeSelector
:update-color-theme="updateColorTheme"
:current-theme="theme.preferred"
:theme-options="themeOptions"
:system-theme-color="systemTheme"
/>
</section>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(projectListLayouts.title) }}</h2>
<p class="mb-4">{{ formatMessage(projectListLayouts.description) }}</p>
<div class="project-lists">
<div v-for="projectType in listTypes" :key="projectType.id + '-project-list-layouts'">
<div class="label">
<div class="label__title">
{{
projectListLayouts[projectType.id]
? formatMessage(projectListLayouts[projectType.id])
: projectType.id
}}
</div>
</div>
<div class="project-list-layouts">
<button
class="preview-radio button-base"
:class="{
selected: cosmetics.searchDisplayMode[projectType.id] === 'list',
}"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'list')"
>
<div class="preview">
<div class="layout-list-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Rows
</div>
</button>
<button
class="preview-radio button-base"
:class="{
selected: cosmetics.searchDisplayMode[projectType.id] === 'grid',
}"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
>
<div class="preview">
<div class="layout-grid-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Grid
</div>
</button>
<button
class="preview-radio button-base"
:class="{
selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery',
}"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
>
<div class="preview">
<div class="layout-gallery-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Gallery
</div>
</button>
</div>
</div>
</div>
</section>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(toggleFeatures.title) }}</h2>
<p class="mb-4">{{ formatMessage(toggleFeatures.description) }}</p>
<div class="adjacent-input small">
<label for="advanced-rendering">
<span class="label__title">
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
</span>
</label>
<input
id="advanced-rendering"
v-model="cosmetics.advancedRendering"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="external-links-new-tab">
<span class="label__title">
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
</span>
</label>
<input
id="external-links-new-tab"
v-model="cosmetics.externalLinksNewTab"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div v-if="false" class="adjacent-input small">
<label for="modrinth-app-promos">
<span class="label__title">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
</span>
</label>
<input
id="modrinth-app-promos"
v-model="cosmetics.hideModrinthAppPromos"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarDescription) }}
</span>
</label>
<input
id="search-layout-toggle"
v-model="cosmetics.rightSearchLayout"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarDescription) }}
</span>
</label>
<input
id="project-layout-toggle"
v-model="cosmetics.leftContentLayout"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { CodeIcon, RadioButtonCheckedIcon, RadioButtonIcon } from "@modrinth/assets";
import { Button, injectNotificationManager, ThemeSelector } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import MessageBanner from "~/components/ui/MessageBanner.vue";
import type { DisplayLocation } from "~/plugins/cosmetics";
import { isDarkTheme, type Theme } from "~/plugins/theme/index.ts";
import { CodeIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import { Button, injectNotificationManager, ThemeSelector } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import type { DisplayLocation } from '~/plugins/cosmetics'
import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts'
useHead({
title: "Display settings - Modrinth",
});
title: 'Display settings - Modrinth',
})
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const developerModeBanner = defineMessages({
description: {
id: "settings.display.banner.developer-mode.description",
defaultMessage:
"<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode.",
},
deactivate: {
id: "settings.display.banner.developer-mode.button",
defaultMessage: "Deactivate developer mode",
},
});
description: {
id: 'settings.display.banner.developer-mode.description',
defaultMessage:
"<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode.",
},
deactivate: {
id: 'settings.display.banner.developer-mode.button',
defaultMessage: 'Deactivate developer mode',
},
})
const colorTheme = defineMessages({
title: {
id: "settings.display.theme.title",
defaultMessage: "Color theme",
},
description: {
id: "settings.display.theme.description",
defaultMessage: "Select your preferred color theme for Modrinth on this device.",
},
});
title: {
id: 'settings.display.theme.title',
defaultMessage: 'Color theme',
},
description: {
id: 'settings.display.theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
},
})
const projectListLayouts = defineMessages({
title: {
id: "settings.display.project-list-layouts.title",
defaultMessage: "Project list layouts",
},
description: {
id: "settings.display.project-list-layouts.description",
defaultMessage:
"Select your preferred layout for each page that displays project lists on this device.",
},
mod: {
id: "settings.display.project-list-layouts.mod",
defaultMessage: "Mods page",
},
plugin: {
id: "settings.display.project-list-layouts.plugin",
defaultMessage: "Plugins page",
},
datapack: {
id: "settings.display.project-list-layouts.datapack",
defaultMessage: "Data Packs page",
},
shader: {
id: "settings.display.project-list-layouts.shader",
defaultMessage: "Shaders page",
},
resourcepack: {
id: "settings.display.project-list-layouts.resourcepack",
defaultMessage: "Resource Packs page",
},
modpack: {
id: "settings.display.project-list-layouts.modpack",
defaultMessage: "Modpacks page",
},
user: {
id: "settings.display.project-list-layouts.user",
defaultMessage: "User profile pages",
},
collection: {
id: "settings.display.project-list.layouts.collection",
defaultMessage: "Collection",
},
});
title: {
id: 'settings.display.project-list-layouts.title',
defaultMessage: 'Project list layouts',
},
description: {
id: 'settings.display.project-list-layouts.description',
defaultMessage:
'Select your preferred layout for each page that displays project lists on this device.',
},
mod: {
id: 'settings.display.project-list-layouts.mod',
defaultMessage: 'Mods page',
},
plugin: {
id: 'settings.display.project-list-layouts.plugin',
defaultMessage: 'Plugins page',
},
datapack: {
id: 'settings.display.project-list-layouts.datapack',
defaultMessage: 'Data Packs page',
},
shader: {
id: 'settings.display.project-list-layouts.shader',
defaultMessage: 'Shaders page',
},
resourcepack: {
id: 'settings.display.project-list-layouts.resourcepack',
defaultMessage: 'Resource Packs page',
},
modpack: {
id: 'settings.display.project-list-layouts.modpack',
defaultMessage: 'Modpacks page',
},
user: {
id: 'settings.display.project-list-layouts.user',
defaultMessage: 'User profile pages',
},
collection: {
id: 'settings.display.project-list.layouts.collection',
defaultMessage: 'Collection',
},
})
const toggleFeatures = defineMessages({
title: {
id: "settings.display.flags.title",
defaultMessage: "Toggle features",
},
description: {
id: "settings.display.flags.description",
defaultMessage: "Enable or disable certain features on this device.",
},
advancedRenderingTitle: {
id: "settings.display.sidebar.advanced-rendering.title",
defaultMessage: "Advanced rendering",
},
advancedRenderingDescription: {
id: "settings.display.sidebar.advanced-rendering.description",
defaultMessage:
"Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.",
},
externalLinksNewTabTitle: {
id: "settings.display.sidebar.external-links-new-tab.title",
defaultMessage: "Open external links in new tab",
},
externalLinksNewTabDescription: {
id: "settings.display.sidebar.external-links-new-tab.description",
defaultMessage:
"Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.",
},
hideModrinthAppPromosTitle: {
id: "settings.display.sidebar.hide-app-promos.title",
defaultMessage: "Hide Modrinth App promotions",
},
hideModrinthAppPromosDescription: {
id: "settings.display.sidebar.hide-app-promos.description",
defaultMessage:
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
},
rightAlignedFiltersSidebarTitle: {
id: "settings.display.sidebar.right-aligned-filters-sidebar.title",
defaultMessage: "Right-aligned filters sidebar on search pages",
},
rightAlignedFiltersSidebarDescription: {
id: "settings.display.sidebar.right-aligned-filters-sidebar.description",
defaultMessage: "Aligns the filters sidebar to the right of the search results.",
},
leftAlignedContentSidebarTitle: {
id: "settings.display.sidebar.left-aligned-content-sidebar.title",
defaultMessage: "Left-aligned sidebar on content pages",
},
leftAlignedContentSidebarDescription: {
id: "settings.display.sidebar.right-aligned-content-sidebar.description",
defaultMessage: "Aligns the sidebar to the left of the page's content.",
},
});
title: {
id: 'settings.display.flags.title',
defaultMessage: 'Toggle features',
},
description: {
id: 'settings.display.flags.description',
defaultMessage: 'Enable or disable certain features on this device.',
},
advancedRenderingTitle: {
id: 'settings.display.sidebar.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'settings.display.sidebar.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
externalLinksNewTabTitle: {
id: 'settings.display.sidebar.external-links-new-tab.title',
defaultMessage: 'Open external links in new tab',
},
externalLinksNewTabDescription: {
id: 'settings.display.sidebar.external-links-new-tab.description',
defaultMessage:
'Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.',
},
hideModrinthAppPromosTitle: {
id: 'settings.display.sidebar.hide-app-promos.title',
defaultMessage: 'Hide Modrinth App promotions',
},
hideModrinthAppPromosDescription: {
id: 'settings.display.sidebar.hide-app-promos.description',
defaultMessage:
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
},
rightAlignedFiltersSidebarTitle: {
id: 'settings.display.sidebar.right-aligned-filters-sidebar.title',
defaultMessage: 'Right-aligned filters sidebar on search pages',
},
rightAlignedFiltersSidebarDescription: {
id: 'settings.display.sidebar.right-aligned-filters-sidebar.description',
defaultMessage: 'Aligns the filters sidebar to the right of the search results.',
},
leftAlignedContentSidebarTitle: {
id: 'settings.display.sidebar.left-aligned-content-sidebar.title',
defaultMessage: 'Left-aligned sidebar on content pages',
},
leftAlignedContentSidebarDescription: {
id: 'settings.display.sidebar.right-aligned-content-sidebar.description',
defaultMessage: "Aligns the sidebar to the left of the page's content.",
},
})
const cosmetics = useCosmetics();
const flags = useFeatureFlags();
const tags = useTags();
const cosmetics = useCosmetics()
const flags = useFeatureFlags()
const tags = useTags()
const theme = useTheme();
const theme = useTheme()
// On the server the value of native theme can be 'unknown'. To hydrate
// correctly, we need to make sure we aren't using 'unknown' and values between
// server and client renders are in sync.
const serverSystemTheme = useState(() => {
const theme_ = theme.native;
if (theme_ === "unknown") return "light";
return theme_;
});
const theme_ = theme.native
if (theme_ === 'unknown') return 'light'
return theme_
})
const systemTheme = useMountedValue((mounted): Theme => {
const systemTheme_ = mounted ? theme.native : serverSystemTheme.value;
return systemTheme_ === "light" ? theme.preferences.light : theme.preferences.dark;
});
const systemTheme_ = mounted ? theme.native : serverSystemTheme.value
return systemTheme_ === 'light' ? theme.preferences.light : theme.preferences.dark
})
const themeOptions = computed(() => {
const options: ("system" | Theme)[] = ["system", "light", "dark", "oled"];
if (flags.value.developerMode || theme.preferred === "retro") {
options.push("retro");
}
return options;
});
const options: ('system' | Theme)[] = ['system', 'light', 'dark', 'oled']
if (flags.value.developerMode || theme.preferred === 'retro') {
options.push('retro')
}
return options
})
function updateColorTheme(value: Theme | "system") {
if (value !== "system") {
if (isDarkTheme(value)) {
theme.preferences.dark = value;
} else {
theme.preferences.light = value;
}
}
function updateColorTheme(value: Theme | 'system') {
if (value !== 'system') {
if (isDarkTheme(value)) {
theme.preferences.dark = value
} else {
theme.preferences.light = value
}
}
theme.preferred = value;
theme.preferred = value
}
function disableDeveloperMode() {
flags.value.developerMode = !flags.value.developerMode;
saveFeatureFlags();
addNotification({
title: "Developer mode deactivated",
text: "Developer mode has been disabled",
type: "success",
});
flags.value.developerMode = !flags.value.developerMode
saveFeatureFlags()
addNotification({
title: 'Developer mode deactivated',
text: 'Developer mode has been disabled',
type: 'success',
})
}
const listTypes = computed(() => {
const types = tags.value.projectTypes.map((type) => {
return {
id: type.id as DisplayLocation,
name: formatProjectType(type.id) + "s",
display: "the " + formatProjectType(type.id).toLowerCase() + "s search page",
};
});
const types = tags.value.projectTypes.map((type) => {
return {
id: type.id as DisplayLocation,
name: formatProjectType(type.id) + 's',
display: 'the ' + formatProjectType(type.id).toLowerCase() + 's search page',
}
})
types.push({
id: "user" as DisplayLocation,
name: "User profiles",
display: "user pages",
});
types.push({
id: 'user' as DisplayLocation,
name: 'User profiles',
display: 'user pages',
})
return types;
});
return types
})
</script>
<style scoped lang="scss">
.project-lists {
display: flex;
flex-direction: column;
gap: var(--gap-md);
display: flex;
flex-direction: column;
gap: var(--gap-md);
> :first-child .label__title {
margin-top: 0;
}
> :first-child .label__title {
margin-top: 0;
}
.preview {
--_layout-width: 7rem;
--_layout-height: 4.5rem;
--_layout-gap: 0.25rem;
.preview {
--_layout-width: 7rem;
--_layout-height: 4.5rem;
--_layout-gap: 0.25rem;
.example-card {
border-radius: 0.5rem;
width: var(--_layout-width);
height: calc((var(--_layout-height) - 3 * var(--_layout-gap)) / 4);
padding: 0;
}
.example-card {
border-radius: 0.5rem;
width: var(--_layout-width);
height: calc((var(--_layout-height) - 3 * var(--_layout-gap)) / 4);
padding: 0;
}
.layout-list-mode {
display: grid;
grid-template-columns: 1fr;
gap: var(--_layout-gap);
}
.layout-list-mode {
display: grid;
grid-template-columns: 1fr;
gap: var(--_layout-gap);
}
.layout-grid-mode {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--_layout-gap);
.layout-grid-mode {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--_layout-gap);
.example-card {
width: calc((var(--_layout-width) - 2 * var(--_layout-gap)) / 3);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
.example-card {
width: calc((var(--_layout-width) - 2 * var(--_layout-gap)) / 3);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
.layout-gallery-mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--_layout-gap);
.layout-gallery-mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--_layout-gap);
.example-card {
width: calc((var(--_layout-width) - var(--_layout-gap)) / 2);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
}
.example-card {
width: calc((var(--_layout-width) - var(--_layout-gap)) / 2);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
}
}
.project-list-layouts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: var(--gap-lg);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: var(--gap-lg);
.preview-radio .example-card {
border: 2px solid transparent;
}
.preview-radio .example-card {
border: 2px solid transparent;
}
.preview-radio.selected .example-card {
border-color: var(--color-brand);
background-color: var(--color-brand-highlight);
}
.preview-radio.selected .example-card {
border-color: var(--color-brand);
background-color: var(--color-brand-highlight);
}
.preview {
display: flex;
align-items: center;
justify-content: center;
}
.preview {
display: flex;
align-items: center;
justify-content: center;
}
}
.developer-message {
svg {
vertical-align: middle;
margin-bottom: 2px;
margin-right: 0.5rem;
}
svg {
vertical-align: middle;
margin-bottom: 2px;
margin-right: 0.5rem;
}
.btn {
margin-top: var(--gap-sm);
}
.btn {
margin-top: var(--gap-sm);
}
}
</style>

View File

@@ -1,526 +1,527 @@
<script setup lang="ts">
import Fuse from "fuse.js/dist/fuse.basic";
import { commonSettingsMessages } from "@modrinth/ui";
import { RadioButtonIcon, RadioButtonCheckedIcon, IssuesIcon } from "@modrinth/assets";
import { isModifierKeyDown } from "~/helpers/events.ts";
import { IssuesIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import { commonSettingsMessages } from '@modrinth/ui'
import Fuse from 'fuse.js/dist/fuse.basic'
const vintl = useVIntl();
const { formatMessage } = vintl;
import { isModifierKeyDown } from '~/helpers/events.ts'
const vintl = useVIntl()
const { formatMessage } = vintl
const messages = defineMessages({
languagesDescription: {
id: "settings.language.description",
defaultMessage:
"Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.",
},
automaticLocale: {
id: "settings.language.languages.automatic",
defaultMessage: "Sync with the system language",
},
noResults: {
id: "settings.language.languages.search.no-results",
defaultMessage: "No languages match your search.",
},
searchFieldDescription: {
id: "settings.language.languages.search-field.description",
defaultMessage: "Submit to focus the first search result",
},
searchFieldPlaceholder: {
id: "settings.language.languages.search-field.placeholder",
defaultMessage: "Search for a language...",
},
searchResultsAnnouncement: {
id: "settings.language.languages.search-results-announcement",
defaultMessage:
"{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.",
},
loadFailed: {
id: "settings.language.languages.load-failed",
defaultMessage: "Cannot load this language. Try again in a bit.",
},
languageLabelApplying: {
id: "settings.language.languages.language-label-applying",
defaultMessage: "{label}. Applying...",
},
languageLabelError: {
id: "settings.language.languages.language-label-error",
defaultMessage: "{label}. Error",
},
});
languagesDescription: {
id: 'settings.language.description',
defaultMessage:
'Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
},
automaticLocale: {
id: 'settings.language.languages.automatic',
defaultMessage: 'Sync with the system language',
},
noResults: {
id: 'settings.language.languages.search.no-results',
defaultMessage: 'No languages match your search.',
},
searchFieldDescription: {
id: 'settings.language.languages.search-field.description',
defaultMessage: 'Submit to focus the first search result',
},
searchFieldPlaceholder: {
id: 'settings.language.languages.search-field.placeholder',
defaultMessage: 'Search for a language...',
},
searchResultsAnnouncement: {
id: 'settings.language.languages.search-results-announcement',
defaultMessage:
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
},
loadFailed: {
id: 'settings.language.languages.load-failed',
defaultMessage: 'Cannot load this language. Try again in a bit.',
},
languageLabelApplying: {
id: 'settings.language.languages.language-label-applying',
defaultMessage: '{label}. Applying...',
},
languageLabelError: {
id: 'settings.language.languages.language-label-error',
defaultMessage: '{label}. Error',
},
})
const categoryNames = defineMessages({
auto: {
id: "settings.language.categories.auto",
defaultMessage: "Automatic",
},
default: {
id: "settings.language.categories.default",
defaultMessage: "Standard languages",
},
fun: {
id: "settings.language.categories.fun",
defaultMessage: "Fun languages",
},
experimental: {
id: "settings.language.categories.experimental",
defaultMessage: "Experimental languages",
},
searchResult: {
id: "settings.language.categories.search-result",
defaultMessage: "Search results",
},
});
auto: {
id: 'settings.language.categories.auto',
defaultMessage: 'Automatic',
},
default: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
fun: {
id: 'settings.language.categories.fun',
defaultMessage: 'Fun languages',
},
experimental: {
id: 'settings.language.categories.experimental',
defaultMessage: 'Experimental languages',
},
searchResult: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
})
type Category = keyof typeof categoryNames;
type Category = keyof typeof categoryNames
const categoryOrder: Category[] = ["auto", "default", "fun", "experimental"];
const categoryOrder: Category[] = ['auto', 'default', 'fun', 'experimental']
function normalizeCategoryName(name?: string): keyof typeof categoryNames {
switch (name) {
case "auto":
case "fun":
case "experimental":
return name;
default:
return "default";
}
switch (name) {
case 'auto':
case 'fun':
case 'experimental':
return name
default:
return 'default'
}
}
type LocaleBase = {
category: Category;
tag: string;
searchTerms?: string[];
};
type AutomaticLocale = LocaleBase & {
auto: true;
};
type CommonLocale = LocaleBase & {
auto?: never;
displayName: string;
defaultName: string;
translatedName: string;
};
type Locale = AutomaticLocale | CommonLocale;
const $defaultNames = useDisplayNames(() => vintl.defaultLocale);
const $translatedNames = useDisplayNames(() => vintl.locale);
const $locales = computed(() => {
const locales: Locale[] = [];
locales.push({
auto: true,
tag: "auto",
category: "auto",
searchTerms: [
"automatic",
"Sync with the system language",
formatMessage(messages.automaticLocale),
],
});
for (const locale of vintl.availableLocales) {
let displayName = locale.meta?.displayName;
if (displayName == null) {
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag;
}
let defaultName = vintl.defaultResources["languages.json"]?.[locale.tag];
if (defaultName == null) {
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag;
}
let translatedName = vintl.resources["languages.json"]?.[locale.tag];
if (translatedName == null) {
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag;
}
let searchTerms = locale.meta?.searchTerms;
if (searchTerms === "-") searchTerms = undefined;
locales.push({
tag: locale.tag,
category: normalizeCategoryName(locale.meta?.category),
displayName,
defaultName,
translatedName,
searchTerms: searchTerms?.split("\n"),
});
}
return locales;
});
const $query = ref("");
const isQueryEmpty = () => $query.value.trim().length === 0;
const fuse = new Fuse<Locale>([], {
keys: ["tag", "displayName", "translatedName", "englishName", "searchTerms"],
threshold: 0.4,
distance: 100,
});
watchSyncEffect(() => fuse.setCollection($locales.value));
const $categories = computed(() => {
const categories = new Map<Category, Locale[]>();
for (const category of categoryOrder) categories.set(category, []);
for (const locale of $locales.value) {
let categoryLocales = categories.get(locale.category);
if (categoryLocales == null) {
categoryLocales = [];
categories.set(locale.category, categoryLocales);
}
categoryLocales.push(locale);
}
for (const categoryKey of [...categories.keys()]) {
if (categories.get(categoryKey)?.length === 0) {
categories.delete(categoryKey);
}
}
return categories;
});
const $searchResults = computed(() => {
return new Map<Category, Locale[]>([
["searchResult", isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
]);
});
const $displayCategories = computed(() =>
isQueryEmpty() ? $categories.value : $searchResults.value,
);
const $changingTo = ref<string | undefined>();
const isChanging = () => $changingTo.value != null;
const $failedLocale = ref<string>();
const $activeLocale = computed(() => {
if ($changingTo.value != null) return $changingTo.value;
return vintl.automatic ? "auto" : vintl.locale;
});
async function changeLocale(value: string) {
if ($activeLocale.value === value) return;
$changingTo.value = value;
try {
await vintl.changeLocale(value);
$failedLocale.value = undefined;
} catch {
$failedLocale.value = value;
} finally {
$changingTo.value = undefined;
}
category: Category
tag: string
searchTerms?: string[]
}
const $languagesList = ref<HTMLDivElement | undefined>();
type AutomaticLocale = LocaleBase & {
auto: true
}
type CommonLocale = LocaleBase & {
auto?: never
displayName: string
defaultName: string
translatedName: string
}
type Locale = AutomaticLocale | CommonLocale
const $defaultNames = useDisplayNames(() => vintl.defaultLocale)
const $translatedNames = useDisplayNames(() => vintl.locale)
const $locales = computed(() => {
const locales: Locale[] = []
locales.push({
auto: true,
tag: 'auto',
category: 'auto',
searchTerms: [
'automatic',
'Sync with the system language',
formatMessage(messages.automaticLocale),
],
})
for (const locale of vintl.availableLocales) {
let displayName = locale.meta?.displayName
if (displayName == null) {
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag
}
let defaultName = vintl.defaultResources['languages.json']?.[locale.tag]
if (defaultName == null) {
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag
}
let translatedName = vintl.resources['languages.json']?.[locale.tag]
if (translatedName == null) {
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag
}
let searchTerms = locale.meta?.searchTerms
if (searchTerms === '-') searchTerms = undefined
locales.push({
tag: locale.tag,
category: normalizeCategoryName(locale.meta?.category),
displayName,
defaultName,
translatedName,
searchTerms: searchTerms?.split('\n'),
})
}
return locales
})
const $query = ref('')
const isQueryEmpty = () => $query.value.trim().length === 0
const fuse = new Fuse<Locale>([], {
keys: ['tag', 'displayName', 'translatedName', 'englishName', 'searchTerms'],
threshold: 0.4,
distance: 100,
})
watchSyncEffect(() => fuse.setCollection($locales.value))
const $categories = computed(() => {
const categories = new Map<Category, Locale[]>()
for (const category of categoryOrder) categories.set(category, [])
for (const locale of $locales.value) {
let categoryLocales = categories.get(locale.category)
if (categoryLocales == null) {
categoryLocales = []
categories.set(locale.category, categoryLocales)
}
categoryLocales.push(locale)
}
for (const categoryKey of [...categories.keys()]) {
if (categories.get(categoryKey)?.length === 0) {
categories.delete(categoryKey)
}
}
return categories
})
const $searchResults = computed(() => {
return new Map<Category, Locale[]>([
['searchResult', isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
])
})
const $displayCategories = computed(() =>
isQueryEmpty() ? $categories.value : $searchResults.value,
)
const $changingTo = ref<string | undefined>()
const isChanging = () => $changingTo.value != null
const $failedLocale = ref<string>()
const $activeLocale = computed(() => {
if ($changingTo.value != null) return $changingTo.value
return vintl.automatic ? 'auto' : vintl.locale
})
async function changeLocale(value: string) {
if ($activeLocale.value === value) return
$changingTo.value = value
try {
await vintl.changeLocale(value)
$failedLocale.value = undefined
} catch {
$failedLocale.value = value
} finally {
$changingTo.value = undefined
}
}
const $languagesList = ref<HTMLDivElement | undefined>()
function onSearchKeydown(e: KeyboardEvent) {
if (e.key !== "Enter" || isModifierKeyDown(e)) return;
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
const focusableTarget = $languagesList.value?.querySelector(
'input, [tabindex]:not([tabindex="-1"])',
) as HTMLElement | undefined;
const focusableTarget = $languagesList.value?.querySelector(
'input, [tabindex]:not([tabindex="-1"])',
) as HTMLElement | undefined
focusableTarget?.focus();
focusableTarget?.focus()
}
function onItemKeydown(e: KeyboardEvent, locale: Locale) {
switch (e.key) {
case "Enter":
case " ":
break;
default:
return;
}
switch (e.key) {
case 'Enter':
case ' ':
break
default:
return
}
if (isModifierKeyDown(e) || isChanging()) return;
if (isModifierKeyDown(e) || isChanging()) return
changeLocale(locale.tag);
changeLocale(locale.tag)
}
function onItemClick(e: MouseEvent, locale: Locale) {
if (isModifierKeyDown(e) || isChanging()) return;
if (isModifierKeyDown(e) || isChanging()) return
changeLocale(locale.tag);
changeLocale(locale.tag)
}
function getItemLabel(locale: Locale) {
const label = locale.auto
? formatMessage(messages.automaticLocale)
: `${locale.translatedName}. ${locale.displayName}`;
const label = locale.auto
? formatMessage(messages.automaticLocale)
: `${locale.translatedName}. ${locale.displayName}`
if ($changingTo.value === locale.tag) {
return formatMessage(messages.languageLabelApplying, { label });
}
if ($changingTo.value === locale.tag) {
return formatMessage(messages.languageLabelApplying, { label })
}
if ($failedLocale.value === locale.tag) {
return formatMessage(messages.languageLabelError, { label });
}
if ($failedLocale.value === locale.tag) {
return formatMessage(messages.languageLabelError, { label })
}
return label;
return label
}
</script>
<template>
<div>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.language) }}</h2>
<div>
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.language) }}</h2>
<div class="card-description">
<IntlFormatted :message-id="messages.languagesDescription">
<template #crowdin-link="{ children }">
<a href="https://crowdin.com/project/modrinth">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<div class="card-description">
<IntlFormatted :message-id="messages.languagesDescription">
<template #crowdin-link="{ children }">
<a href="https://crowdin.com/project/modrinth">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<div class="search-container">
<input
id="language-search"
v-model="$query"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
class="language-search"
aria-describedby="language-search-description"
:disabled="isChanging()"
@keydown="onSearchKeydown"
/>
<div class="search-container">
<input
id="language-search"
v-model="$query"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
class="language-search"
aria-describedby="language-search-description"
:disabled="isChanging()"
@keydown="onSearchKeydown"
/>
<div id="language-search-description" class="visually-hidden">
{{ formatMessage(messages.searchFieldDescription) }}
</div>
<div id="language-search-description" class="visually-hidden">
{{ formatMessage(messages.searchFieldDescription) }}
</div>
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
{{
isQueryEmpty()
? ""
: formatMessage(messages.searchResultsAnnouncement, {
matches: $searchResults.get("searchResult")?.length ?? 0,
})
}}
</div>
</div>
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
{{
isQueryEmpty()
? ''
: formatMessage(messages.searchResultsAnnouncement, {
matches: $searchResults.get('searchResult')?.length ?? 0,
})
}}
</div>
</div>
<div ref="$languagesList" class="languages-list">
<template v-for="[category, locales] in $displayCategories" :key="category">
<strong class="category-name">
{{ formatMessage(categoryNames[category]) }}
</strong>
<div ref="$languagesList" class="languages-list">
<template v-for="[category, locales] in $displayCategories" :key="category">
<strong class="category-name">
{{ formatMessage(categoryNames[category]) }}
</strong>
<div
v-if="category === 'searchResult' && locales.length === 0"
class="no-results"
tabindex="0"
>
{{ formatMessage(messages.noResults) }}
</div>
<div
v-if="category === 'searchResult' && locales.length === 0"
class="no-results"
tabindex="0"
>
{{ formatMessage(messages.noResults) }}
</div>
<template v-for="locale in locales" :key="locale.tag">
<div
role="button"
:aria-pressed="$activeLocale === locale.tag"
:class="{
'language-item': true,
pending: $changingTo == locale.tag,
errored: $failedLocale == locale.tag,
}"
:aria-describedby="
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
"
:aria-disabled="isChanging() && $changingTo !== locale.tag"
:tabindex="0"
:aria-label="getItemLabel(locale)"
@click="(e) => onItemClick(e, locale)"
@keydown="(e) => onItemKeydown(e, locale)"
>
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
<RadioButtonIcon v-else class="radio" />
<template v-for="locale in locales" :key="locale.tag">
<div
role="button"
:aria-pressed="$activeLocale === locale.tag"
:class="{
'language-item': true,
pending: $changingTo == locale.tag,
errored: $failedLocale == locale.tag,
}"
:aria-describedby="
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
"
:aria-disabled="isChanging() && $changingTo !== locale.tag"
:tabindex="0"
:aria-label="getItemLabel(locale)"
@click="(e) => onItemClick(e, locale)"
@keydown="(e) => onItemKeydown(e, locale)"
>
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
<RadioButtonIcon v-else class="radio" />
<div class="language-names">
<div class="language-name">
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
</div>
<div class="language-names">
<div class="language-name">
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
</div>
<div v-if="!locale.auto" class="language-translated-name">
{{ locale.translatedName }}
</div>
</div>
</div>
<div v-if="!locale.auto" class="language-translated-name">
{{ locale.translatedName }}
</div>
</div>
</div>
<div
v-if="$failedLocale === locale.tag"
:id="`language__${locale.tag}__fail`"
class="language-load-error"
>
<IssuesIcon />
{{ formatMessage(messages.loadFailed) }}
</div>
</template>
</template>
</div>
</section>
</div>
<div
v-if="$failedLocale === locale.tag"
:id="`language__${locale.tag}__fail`"
class="language-load-error"
>
<IssuesIcon />
{{ formatMessage(messages.loadFailed) }}
</div>
</template>
</template>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
.languages-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.language-item {
display: flex;
align-items: center;
column-gap: 0.5rem;
border: 0.15rem solid transparent;
border-radius: var(--spacing-card-md);
background: var(--color-button-bg);
padding: var(--spacing-card-md);
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
column-gap: 0.5rem;
border: 0.15rem solid transparent;
border-radius: var(--spacing-card-md);
background: var(--color-button-bg);
padding: var(--spacing-card-md);
cursor: pointer;
position: relative;
overflow: hidden;
&:not([aria-disabled="true"]):hover {
border-color: var(--color-button-bg-hover);
}
&:not([aria-disabled='true']):hover {
border-color: var(--color-button-bg-hover);
}
&:focus-visible,
&:has(:focus-visible) {
outline: 2px solid var(--color-brand);
}
&:focus-visible,
&:has(:focus-visible) {
outline: 2px solid var(--color-brand);
}
&.errored {
border-color: var(--color-red);
&.errored {
border-color: var(--color-red);
&:hover {
border-color: var(--color-red);
}
}
&:hover {
border-color: var(--color-red);
}
}
&.pending::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
&.pending::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
102deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0) 20%,
rgba(0, 0, 0, 0.1) 45%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0) 80%,
rgba(0, 0, 0, 0) 100%
);
background-image: linear-gradient(
102deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0) 20%,
rgba(0, 0, 0, 0.1) 45%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0) 80%,
rgba(0, 0, 0, 0) 100%
);
background-repeat: no-repeat;
animation: shimmerSliding 2.5s ease-out infinite;
background-repeat: no-repeat;
animation: shimmerSliding 2.5s ease-out infinite;
.dark-mode &,
.oled-mode & {
background-image: linear-gradient(
102deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 20%,
rgba(255, 255, 255, 0.1) 45%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 80%,
rgba(255, 255, 255, 0) 100%
);
}
.dark-mode &,
.oled-mode & {
background-image: linear-gradient(
102deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 20%,
rgba(255, 255, 255, 0.1) 45%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 80%,
rgba(255, 255, 255, 0) 100%
);
}
@keyframes shimmerSliding {
from {
left: -100%;
}
to {
left: 100%;
}
}
}
@keyframes shimmerSliding {
from {
left: -100%;
}
to {
left: 100%;
}
}
}
&[aria-disabled="true"]:not(.pending) {
opacity: 0.8;
pointer-events: none;
cursor: default;
}
&[aria-disabled='true']:not(.pending) {
opacity: 0.8;
pointer-events: none;
cursor: default;
}
}
.language-load-error {
color: var(--color-red);
font-size: var(--font-size-sm);
margin-left: 0.3rem;
display: flex;
align-items: center;
gap: 0.3rem;
color: var(--color-red);
font-size: var(--font-size-sm);
margin-left: 0.3rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.radio {
width: 24px;
height: 24px;
width: 24px;
height: 24px;
}
.language-names {
display: flex;
justify-content: space-between;
flex: 1;
flex-wrap: wrap;
display: flex;
justify-content: space-between;
flex: 1;
flex-wrap: wrap;
}
.language-name {
font-weight: bold;
font-weight: bold;
}
.language-search {
width: 100%;
width: 100%;
}
.search-container {
margin-bottom: var(--spacing-card-md);
margin-bottom: var(--spacing-card-md);
}
.card-description {
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
a {
color: var(--color-link);
a {
color: var(--color-link);
&:hover {
color: var(--color-link-hover);
}
&:hover {
color: var(--color-link-hover);
}
&:active {
color: var(--color-link-active);
}
}
&:active {
color: var(--color-link-active);
}
}
}
.category-name {
margin-top: var(--spacing-card-md);
margin-top: var(--spacing-card-md);
}
</style>

View File

@@ -1,430 +1,431 @@
<template>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(deleteModalMessages.title)"
:description="formatMessage(deleteModalMessages.description)"
:proceed-label="formatMessage(deleteModalMessages.action)"
@proceed="removePat(deletePatIndex)"
/>
<Modal
ref="patModal"
:header="
editPatIndex !== null
? formatMessage(createModalMessages.editTitle)
: formatMessage(createModalMessages.createTitle)
"
>
<div class="universal-modal">
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
</label>
<input
id="pat-name"
v-model="name"
maxlength="2048"
type="email"
:placeholder="formatMessage(createModalMessages.namePlaceholder)"
/>
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div id="pat-scopes" class="checkboxes">
<Checkbox
v-for="scope in scopeList"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
/>
</div>
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</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 />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
v-if="editPatIndex !== null"
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="editPat"
>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
<button
v-else
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="createPat"
>
<PlusIcon />
{{ formatMessage(createModalMessages.action) }}
</button>
</div>
</div>
</Modal>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(deleteModalMessages.title)"
:description="formatMessage(deleteModalMessages.description)"
:proceed-label="formatMessage(deleteModalMessages.action)"
@proceed="removePat(deletePatIndex)"
/>
<Modal
ref="patModal"
:header="
editPatIndex !== null
? formatMessage(createModalMessages.editTitle)
: formatMessage(createModalMessages.createTitle)
"
>
<div class="universal-modal">
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
</label>
<input
id="pat-name"
v-model="name"
maxlength="2048"
type="email"
:placeholder="formatMessage(createModalMessages.namePlaceholder)"
/>
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div id="pat-scopes" class="checkboxes">
<Checkbox
v-for="scope in scopeList"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
/>
</div>
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</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 />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
v-if="editPatIndex !== null"
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="editPat"
>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
<button
v-else
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="createPat"
>
<PlusIcon />
{{ formatMessage(createModalMessages.action) }}
</button>
</div>
</div>
</Modal>
<div class="header__row">
<div class="header__title">
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.pats) }}</h2>
</div>
<button
class="btn btn-primary"
@click="
() => {
name = null;
scopesVal = 0;
expires = null;
editPatIndex = null;
$refs.patModal.show();
}
"
>
<PlusIcon /> {{ formatMessage(messages.create) }}
</button>
</div>
<p>
<IntlFormatted :message-id="messages.description">
<template #doc-link="{ children }">
<a class="text-link" href="https://docs.modrinth.com">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</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
? formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.last_used),
time: new Date(pat.last_used),
})
: null
"
>
<template v-if="pat.last_used">
{{
formatMessage(tokenMessages.lastUsed, {
ago: formatRelativeTime(pat.last_used),
})
}}
</template>
<template v-else>{{ formatMessage(tokenMessages.neverUsed) }}</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.expires),
time: new Date(pat.expires),
})
"
>
<template v-if="new Date(pat.expires) > new Date()">
{{
formatMessage(tokenMessages.expiresIn, {
inTime: formatRelativeTime(pat.expires),
})
}}
</template>
<template v-else>
{{
formatMessage(tokenMessages.expiredAgo, {
ago: formatRelativeTime(pat.expires),
})
}}
</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.created),
time: new Date(pat.created),
})
"
>
{{
formatMessage(commonMessages.createdAgoLabel, {
ago: formatRelativeTime(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 /> {{ formatMessage(tokenMessages.edit) }}
</button>
<button
class="iconified-button raised-button"
@click="
() => {
deletePatIndex = pat.id;
$refs.modal_confirm.show();
}
"
>
<TrashIcon /> {{ formatMessage(tokenMessages.revoke) }}
</button>
</div>
</div>
</div>
<div class="header__row">
<div class="header__title">
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.pats) }}</h2>
</div>
<button
class="btn btn-primary"
@click="
() => {
name = null
scopesVal = 0
expires = null
editPatIndex = null
$refs.patModal.show()
}
"
>
<PlusIcon /> {{ formatMessage(messages.create) }}
</button>
</div>
<p>
<IntlFormatted :message-id="messages.description">
<template #doc-link="{ children }">
<a class="text-link" href="https://docs.modrinth.com">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</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
? formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.last_used),
time: new Date(pat.last_used),
})
: null
"
>
<template v-if="pat.last_used">
{{
formatMessage(tokenMessages.lastUsed, {
ago: formatRelativeTime(pat.last_used),
})
}}
</template>
<template v-else>{{ formatMessage(tokenMessages.neverUsed) }}</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.expires),
time: new Date(pat.expires),
})
"
>
<template v-if="new Date(pat.expires) > new Date()">
{{
formatMessage(tokenMessages.expiresIn, {
inTime: formatRelativeTime(pat.expires),
})
}}
</template>
<template v-else>
{{
formatMessage(tokenMessages.expiredAgo, {
ago: formatRelativeTime(pat.expires),
})
}}
</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.created),
time: new Date(pat.created),
})
"
>
{{
formatMessage(commonMessages.createdAgoLabel, {
ago: formatRelativeTime(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 /> {{ formatMessage(tokenMessages.edit) }}
</button>
<button
class="iconified-button raised-button"
@click="
() => {
deletePatIndex = pat.id
$refs.modal_confirm.show()
}
"
>
<TrashIcon /> {{ formatMessage(tokenMessages.revoke) }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { EditIcon, PlusIcon, SaveIcon, TrashIcon, XIcon } from "@modrinth/assets";
import { EditIcon, PlusIcon, SaveIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
Checkbox,
ConfirmModal,
CopyCode,
commonMessages,
commonSettingsMessages,
injectNotificationManager,
useRelativeTime,
} from "@modrinth/ui";
import Modal from "~/components/ui/Modal.vue";
Checkbox,
commonMessages,
commonSettingsMessages,
ConfirmModal,
CopyCode,
injectNotificationManager,
useRelativeTime,
} from '@modrinth/ui'
import Modal from '~/components/ui/Modal.vue'
import {
getScopeValue,
hasScope,
scopeList,
toggleScope,
useScopes,
} from "~/composables/auth/scopes.ts";
getScopeValue,
hasScope,
scopeList,
toggleScope,
useScopes,
} from '~/composables/auth/scopes.ts'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime();
const formatRelativeTime = useRelativeTime()
const createModalMessages = defineMessages({
createTitle: {
id: "settings.pats.modal.create.title",
defaultMessage: "Create personal access token",
},
editTitle: {
id: "settings.pats.modal.edit.title",
defaultMessage: "Edit personal access token",
},
nameLabel: {
id: "settings.pats.modal.create.name.label",
defaultMessage: "Name",
},
namePlaceholder: {
id: "settings.pats.modal.create.name.placeholder",
defaultMessage: "Enter the PAT's name...",
},
expiresLabel: {
id: "settings.pats.modal.create.expires.label",
defaultMessage: "Expires",
},
action: {
id: "settings.pats.modal.create.action",
defaultMessage: "Create PAT",
},
});
createTitle: {
id: 'settings.pats.modal.create.title',
defaultMessage: 'Create personal access token',
},
editTitle: {
id: 'settings.pats.modal.edit.title',
defaultMessage: 'Edit personal access token',
},
nameLabel: {
id: 'settings.pats.modal.create.name.label',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'settings.pats.modal.create.name.placeholder',
defaultMessage: "Enter the PAT's name...",
},
expiresLabel: {
id: 'settings.pats.modal.create.expires.label',
defaultMessage: 'Expires',
},
action: {
id: 'settings.pats.modal.create.action',
defaultMessage: 'Create PAT',
},
})
const deleteModalMessages = defineMessages({
title: {
id: "settings.pats.modal.delete.title",
defaultMessage: "Are you sure you want to delete this token?",
},
description: {
id: "settings.pats.modal.delete.description",
defaultMessage: "This will remove this token forever (like really forever).",
},
action: {
id: "settings.pats.modal.delete.action",
defaultMessage: "Delete this token",
},
});
title: {
id: 'settings.pats.modal.delete.title',
defaultMessage: 'Are you sure you want to delete this token?',
},
description: {
id: 'settings.pats.modal.delete.description',
defaultMessage: 'This will remove this token forever (like really forever).',
},
action: {
id: 'settings.pats.modal.delete.action',
defaultMessage: 'Delete this token',
},
})
const messages = defineMessages({
description: {
id: "settings.pats.description",
defaultMessage:
"PATs can be used to access Modrinth's API. For more information, see <doc-link>Modrinth's API documentation</doc-link>. They can be created and revoked at any time.",
},
create: {
id: "settings.pats.action.create",
defaultMessage: "Create a PAT",
},
});
description: {
id: 'settings.pats.description',
defaultMessage:
"PATs can be used to access Modrinth's API. For more information, see <doc-link>Modrinth's API documentation</doc-link>. They can be created and revoked at any time.",
},
create: {
id: 'settings.pats.action.create',
defaultMessage: 'Create a PAT',
},
})
const tokenMessages = defineMessages({
edit: {
id: "settings.pats.token.action.edit",
defaultMessage: "Edit token",
},
revoke: {
id: "settings.pats.token.action.revoke",
defaultMessage: "Revoke token",
},
lastUsed: {
id: "settings.pats.token.last-used",
defaultMessage: "Last used {ago}",
},
neverUsed: {
id: "settings.pats.token.never-used",
defaultMessage: "Never used",
},
expiresIn: {
id: "settings.pats.token.expires-in",
defaultMessage: "Expires {inTime}",
},
expiredAgo: {
id: "settings.pats.token.expired-ago",
defaultMessage: "Expired {ago}",
},
});
edit: {
id: 'settings.pats.token.action.edit',
defaultMessage: 'Edit token',
},
revoke: {
id: 'settings.pats.token.action.revoke',
defaultMessage: 'Revoke token',
},
lastUsed: {
id: 'settings.pats.token.last-used',
defaultMessage: 'Last used {ago}',
},
neverUsed: {
id: 'settings.pats.token.never-used',
defaultMessage: 'Never used',
},
expiresIn: {
id: 'settings.pats.token.expires-in',
defaultMessage: 'Expires {inTime}',
},
expiredAgo: {
id: 'settings.pats.token.expired-ago',
defaultMessage: 'Expired {ago}',
},
})
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
useHead({
title: `${formatMessage(commonSettingsMessages.pats)} - Modrinth`,
});
title: `${formatMessage(commonSettingsMessages.pats)} - Modrinth`,
})
const data = useNuxtApp();
const { scopesToLabels } = useScopes();
const patModal = ref();
const data = useNuxtApp()
const { scopesToLabels } = useScopes()
const patModal = ref()
const editPatIndex = ref(null);
const editPatIndex = ref(null)
const name = ref(null);
const scopesVal = ref(BigInt(0));
const expires = ref(null);
const name = ref(null)
const scopesVal = ref(BigInt(0))
const expires = ref(null)
const deletePatIndex = ref(null);
const deletePatIndex = ref(null)
const loading = ref(false);
const loading = ref(false)
const { data: pats, refresh } = await useAsyncData("pat", () => useBaseFetch("pat"));
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: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
});
pats.value.push(res);
patModal.value.hide();
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
}
loading.value = false;
stopLoading();
startLoading()
loading.value = true
try {
const res = await useBaseFetch('pat', {
method: 'POST',
body: {
name: name.value,
scopes: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
})
pats.value.push(res)
patModal.value.hide()
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
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: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
});
await refresh();
patModal.value.hide();
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
}
loading.value = false;
stopLoading();
startLoading()
loading.value = true
try {
await useBaseFetch(`pat/${pats.value[editPatIndex.value].id}`, {
method: 'PATCH',
body: {
name: name.value,
scopes: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
})
await refresh()
patModal.value.hide()
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
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) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
}
stopLoading();
startLoading()
try {
pats.value = pats.value.filter((x) => x.id !== id)
await useBaseFetch(`pat/${id}`, {
method: 'DELETE',
})
await refresh()
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.checkboxes {
display: grid;
column-gap: 0.5rem;
display: grid;
column-gap: 0.5rem;
@media screen and (min-width: 432px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 432px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 800px) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (min-width: 800px) {
grid-template-columns: repeat(3, 1fr);
}
}
.token {
display: flex;
flex-direction: column;
gap: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
.input-group {
margin-left: auto;
}
}
}
</style>

View File

@@ -1,252 +1,252 @@
<template>
<div>
<section class="card">
<h2 class="text-2xl">{{ formatMessage(messages.title) }}</h2>
<p class="mb-4">
<IntlFormatted :message-id="messages.description">
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<label>
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
</label>
<div class="avatar-changer">
<Avatar
:src="previewImage ? previewImage : avatarUrl"
size="md"
circle
:alt="auth.user.username"
/>
<div class="input-stack">
<FileInput
:max-size="262144"
:show-icon="true"
class="btn"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<Button v-if="avatarUrl !== null" :action="removePreviewImage">
<TrashIcon />
{{ formatMessage(commonMessages.removeImageButton) }}
</Button>
<Button
v-if="previewImage"
:action="
() => {
icon = null;
previewImage = null;
}
"
>
<UndoIcon />
{{ formatMessage(messages.profilePictureReset) }}
</Button>
</div>
</div>
<label for="username-field">
<span class="label__title">{{ formatMessage(messages.usernameTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.usernameDescription) }}
</span>
</label>
<input id="username-field" v-model="username" type="text" />
<label for="bio-field">
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.bioDescription) }}
</span>
</label>
<textarea id="bio-field" v-model="bio" type="text" />
<div v-if="hasUnsavedChanges" class="input-group">
<Button color="primary" :action="() => saveChanges()">
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
<Button :action="() => cancel()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
<div v-else class="input-group">
<Button disabled color="primary" :action="() => saveChanges()">
<SaveIcon />
{{
saved
? formatMessage(commonMessages.changesSavedLabel)
: formatMessage(commonMessages.saveChangesButton)
}}
</Button>
<Button :link="`/user/${auth.user.username}`">
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
</Button>
</div>
</section>
</div>
<div>
<section class="card">
<h2 class="text-2xl">{{ formatMessage(messages.title) }}</h2>
<p class="mb-4">
<IntlFormatted :message-id="messages.description">
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<label>
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
</label>
<div class="avatar-changer">
<Avatar
:src="previewImage ? previewImage : avatarUrl"
size="md"
circle
:alt="auth.user.username"
/>
<div class="input-stack">
<FileInput
:max-size="262144"
:show-icon="true"
class="btn"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<Button v-if="avatarUrl !== null" :action="removePreviewImage">
<TrashIcon />
{{ formatMessage(commonMessages.removeImageButton) }}
</Button>
<Button
v-if="previewImage"
:action="
() => {
icon = null
previewImage = null
}
"
>
<UndoIcon />
{{ formatMessage(messages.profilePictureReset) }}
</Button>
</div>
</div>
<label for="username-field">
<span class="label__title">{{ formatMessage(messages.usernameTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.usernameDescription) }}
</span>
</label>
<input id="username-field" v-model="username" type="text" />
<label for="bio-field">
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.bioDescription) }}
</span>
</label>
<textarea id="bio-field" v-model="bio" type="text" />
<div v-if="hasUnsavedChanges" class="input-group">
<Button color="primary" :action="() => saveChanges()">
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
<Button :action="() => cancel()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
<div v-else class="input-group">
<Button disabled color="primary" :action="() => saveChanges()">
<SaveIcon />
{{
saved
? formatMessage(commonMessages.changesSavedLabel)
: formatMessage(commonMessages.saveChangesButton)
}}
</Button>
<Button :link="`/user/${auth.user.username}`">
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
</Button>
</div>
</section>
</div>
</template>
<script setup>
import { SaveIcon, TrashIcon, UndoIcon, UploadIcon, UserIcon, XIcon } from "@modrinth/assets";
import { Avatar, Button, commonMessages, FileInput, injectNotificationManager } from "@modrinth/ui";
import { SaveIcon, TrashIcon, UndoIcon, UploadIcon, UserIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, commonMessages, FileInput, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
useHead({
title: "Profile settings - Modrinth",
});
title: 'Profile settings - Modrinth',
})
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
const messages = defineMessages({
title: {
id: "settings.profile.profile-info",
defaultMessage: "Profile information",
},
description: {
id: "settings.profile.description",
defaultMessage:
"Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.",
},
profilePicture: {
id: "settings.profile.profile-picture.title",
defaultMessage: "Profile picture",
},
profilePictureReset: {
id: "settings.profile.profile-picture.reset",
defaultMessage: "Reset",
},
usernameTitle: {
id: "settings.profile.username.title",
defaultMessage: "Username",
},
usernameDescription: {
id: "settings.profile.username.description",
defaultMessage: "A unique case-insensitive name to identify your profile.",
},
bioTitle: {
id: "settings.profile.bio.title",
defaultMessage: "Bio",
},
bioDescription: {
id: "settings.profile.bio.description",
defaultMessage: "A short description to tell everyone a little bit about you.",
},
});
title: {
id: 'settings.profile.profile-info',
defaultMessage: 'Profile information',
},
description: {
id: 'settings.profile.description',
defaultMessage:
'Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.',
},
profilePicture: {
id: 'settings.profile.profile-picture.title',
defaultMessage: 'Profile picture',
},
profilePictureReset: {
id: 'settings.profile.profile-picture.reset',
defaultMessage: 'Reset',
},
usernameTitle: {
id: 'settings.profile.username.title',
defaultMessage: 'Username',
},
usernameDescription: {
id: 'settings.profile.username.description',
defaultMessage: 'A unique case-insensitive name to identify your profile.',
},
bioTitle: {
id: 'settings.profile.bio.title',
defaultMessage: 'Bio',
},
bioDescription: {
id: 'settings.profile.bio.description',
defaultMessage: 'A short description to tell everyone a little bit about you.',
},
})
const auth = await useAuth();
const auth = await useAuth()
const username = ref(auth.value.user.username);
const bio = ref(auth.value.user.bio);
const avatarUrl = ref(auth.value.user.avatar_url);
const icon = shallowRef(null);
const previewImage = shallowRef(null);
const pendingAvatarDeletion = ref(false);
const saved = ref(false);
const username = ref(auth.value.user.username)
const bio = ref(auth.value.user.bio)
const avatarUrl = ref(auth.value.user.avatar_url)
const icon = shallowRef(null)
const previewImage = shallowRef(null)
const pendingAvatarDeletion = ref(false)
const saved = ref(false)
const hasUnsavedChanges = computed(
() =>
username.value !== auth.value.user.username ||
bio.value !== auth.value.user.bio ||
previewImage.value,
);
() =>
username.value !== auth.value.user.username ||
bio.value !== auth.value.user.bio ||
previewImage.value,
)
function showPreviewImage(files) {
const reader = new FileReader();
icon.value = files[0];
reader.readAsDataURL(icon.value);
reader.onload = (event) => {
previewImage.value = event.target.result;
};
const reader = new FileReader()
icon.value = files[0]
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
function removePreviewImage() {
pendingAvatarDeletion.value = true;
previewImage.value = "https://cdn.modrinth.com/placeholder.png";
pendingAvatarDeletion.value = true
previewImage.value = 'https://cdn.modrinth.com/placeholder.png'
}
function cancel() {
icon.value = null;
previewImage.value = null;
pendingAvatarDeletion.value = false;
username.value = auth.value.user.username;
bio.value = auth.value.user.bio;
icon.value = null
previewImage.value = null
pendingAvatarDeletion.value = false
username.value = auth.value.user.username
bio.value = auth.value.user.bio
}
async function saveChanges() {
startLoading();
try {
if (pendingAvatarDeletion.value) {
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
method: "DELETE",
});
pendingAvatarDeletion.value = false;
previewImage.value = null;
}
startLoading()
try {
if (pendingAvatarDeletion.value) {
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
method: 'DELETE',
})
pendingAvatarDeletion.value = false
previewImage.value = null
}
if (icon.value) {
await useBaseFetch(
`user/${auth.value.user.id}/icon?ext=${
icon.value.type.split("/")[icon.value.type.split("/").length - 1]
}`,
{
method: "PATCH",
body: icon.value,
},
);
icon.value = null;
previewImage.value = null;
}
if (icon.value) {
await useBaseFetch(
`user/${auth.value.user.id}/icon?ext=${
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
}`,
{
method: 'PATCH',
body: icon.value,
},
)
icon.value = null
previewImage.value = null
}
const body = {};
const body = {}
if (auth.value.user.username !== username.value) {
body.username = username.value;
}
if (auth.value.user.username !== username.value) {
body.username = username.value
}
if (auth.value.user.bio !== bio.value) {
body.bio = bio.value;
}
if (auth.value.user.bio !== bio.value) {
body.bio = bio.value
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: "PATCH",
body,
});
await useAuth(auth.value.token);
avatarUrl.value = auth.value.user.avatar_url;
saved.value = true;
} catch (err) {
addNotification({
title: "An error occurred",
text: err
? err.data
? err.data.description
? err.data.description
: err.data
: err
: "aaaaahhh",
type: "error",
});
}
stopLoading();
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body,
})
await useAuth(auth.value.token)
avatarUrl.value = auth.value.user.avatar_url
saved.value = true
} catch (err) {
addNotification({
title: 'An error occurred',
text: err
? err.data
? err.data.description
? err.data.description
: err.data
: err
: 'aaaaahhh',
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.avatar-changer {
display: flex;
gap: var(--gap-lg);
margin-top: var(--gap-md);
display: flex;
gap: var(--gap-lg);
margin-top: var(--gap-md);
}
textarea {
height: 6rem;
width: 40rem;
margin-bottom: var(--gap-lg);
height: 6rem;
width: 40rem;
margin-bottom: var(--gap-lg);
}
</style>

View File

@@ -1,148 +1,148 @@
<template>
<div class="universal-card">
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.sessions) }}</h2>
<p class="preserve-lines">
{{ formatMessage(messages.sessionsDescription) }}
</p>
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session mt-4">
<div>
<div>
<strong>
{{ session.os ?? formatMessage(messages.unknownOsLabel) }}
{{ session.platform ?? formatMessage(messages.unknownPlatformLabel) }}
{{ session.ip }}
</strong>
</div>
<div>
<template v-if="session.city">{{ session.city }}, {{ session.country }} </template>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(session.last_login),
time: new Date(session.last_login),
})
"
>
{{
formatMessage(messages.lastAccessedAgoLabel, {
ago: formatRelativeTime(session.last_login),
})
}}
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(session.created),
time: new Date(session.created),
})
"
>
{{
formatMessage(messages.createdAgoLabel, {
ago: formatRelativeTime(session.created),
})
}}
</span>
</div>
</div>
<div class="input-group">
<i v-if="session.current">{{ formatMessage(messages.currentSessionLabel) }}</i>
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
<XIcon /> {{ formatMessage(messages.revokeSessionButton) }}
</button>
</div>
</div>
</div>
<div class="universal-card">
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.sessions) }}</h2>
<p class="preserve-lines">
{{ formatMessage(messages.sessionsDescription) }}
</p>
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session mt-4">
<div>
<div>
<strong>
{{ session.os ?? formatMessage(messages.unknownOsLabel) }}
{{ session.platform ?? formatMessage(messages.unknownPlatformLabel) }}
{{ session.ip }}
</strong>
</div>
<div>
<template v-if="session.city">{{ session.city }}, {{ session.country }} </template>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(session.last_login),
time: new Date(session.last_login),
})
"
>
{{
formatMessage(messages.lastAccessedAgoLabel, {
ago: formatRelativeTime(session.last_login),
})
}}
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(session.created),
time: new Date(session.created),
})
"
>
{{
formatMessage(messages.createdAgoLabel, {
ago: formatRelativeTime(session.created),
})
}}
</span>
</div>
</div>
<div class="input-group">
<i v-if="session.current">{{ formatMessage(messages.currentSessionLabel) }}</i>
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
<XIcon /> {{ formatMessage(messages.revokeSessionButton) }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { XIcon } from "@modrinth/assets";
import { XIcon } from '@modrinth/assets'
import {
commonMessages,
commonSettingsMessages,
injectNotificationManager,
useRelativeTime,
} from "@modrinth/ui";
commonMessages,
commonSettingsMessages,
injectNotificationManager,
useRelativeTime,
} from '@modrinth/ui'
definePageMeta({
middleware: "auth",
});
middleware: 'auth',
})
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
const formatRelativeTime = useRelativeTime();
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const messages = defineMessages({
currentSessionLabel: {
id: "settings.sessions.current-session",
defaultMessage: "Current session",
},
revokeSessionButton: {
id: "settings.sessions.action.revoke-session",
defaultMessage: "Revoke session",
},
createdAgoLabel: {
id: "settings.sessions.created-ago",
defaultMessage: "Created {ago}",
},
sessionsDescription: {
id: "settings.sessions.description",
defaultMessage:
"Here are all the devices that are currently logged in with your Modrinth account. You can log out of each one individually.\n\nIf you see an entry you don't recognize, log out of that device and change your Modrinth account password immediately.",
},
lastAccessedAgoLabel: {
id: "settings.sessions.last-accessed-ago",
defaultMessage: "Last accessed {ago}",
},
unknownOsLabel: {
id: "settings.sessions.unknown-os",
defaultMessage: "Unknown OS",
},
unknownPlatformLabel: {
id: "settings.sessions.unknown-platform",
defaultMessage: "Unknown platform",
},
});
currentSessionLabel: {
id: 'settings.sessions.current-session',
defaultMessage: 'Current session',
},
revokeSessionButton: {
id: 'settings.sessions.action.revoke-session',
defaultMessage: 'Revoke session',
},
createdAgoLabel: {
id: 'settings.sessions.created-ago',
defaultMessage: 'Created {ago}',
},
sessionsDescription: {
id: 'settings.sessions.description',
defaultMessage:
"Here are all the devices that are currently logged in with your Modrinth account. You can log out of each one individually.\n\nIf you see an entry you don't recognize, log out of that device and change your Modrinth account password immediately.",
},
lastAccessedAgoLabel: {
id: 'settings.sessions.last-accessed-ago',
defaultMessage: 'Last accessed {ago}',
},
unknownOsLabel: {
id: 'settings.sessions.unknown-os',
defaultMessage: 'Unknown OS',
},
unknownPlatformLabel: {
id: 'settings.sessions.unknown-platform',
defaultMessage: 'Unknown platform',
},
})
useHead({
title: () => `${formatMessage(commonSettingsMessages.sessions)} - Modrinth`,
});
title: () => `${formatMessage(commonSettingsMessages.sessions)} - Modrinth`,
})
const { data: sessions, refresh } = await useAsyncData("session/list", () =>
useBaseFetch("session/list"),
);
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) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: "error",
});
}
stopLoading();
startLoading()
try {
sessions.value = sessions.value.filter((x) => x.id !== id)
await useBaseFetch(`session/${id}`, {
method: 'DELETE',
})
await refresh()
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.session {
display: flex;
flex-direction: column;
gap: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
.input-group {
margin-left: auto;
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More