You've already forked AstralRinth
forked from didirus/AstralRinth
Migrate to Turborepo (#1251)
This commit is contained in:
639
apps/frontend/src/pages/settings/account.vue
Normal file
639
apps/frontend/src/pages/settings/account.vue
Normal file
@@ -0,0 +1,639 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete your account?"
|
||||
description="This will **immediately delete all of your user data and follows**. This will not delete your projects. Deleting your account cannot be reversed.<br><br>If you need help with your account, get support on the [Modrinth Discord](https://discord.modrinth.com)."
|
||||
proceed-label="Delete this account"
|
||||
:confirmation-text="auth.user.username"
|
||||
:has-to-type="true"
|
||||
@proceed="deleteAccount"
|
||||
/>
|
||||
<Modal ref="changeEmailModal" :header="`${auth.user.email ? 'Change' : 'Add'} email`">
|
||||
<div class="universal-modal">
|
||||
<p>Your account information is not displayed publicly.</p>
|
||||
<label for="email-input"><span class="label__title">Email address</span> </label>
|
||||
<input
|
||||
id="email-input"
|
||||
v-model="email"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="`Enter your email address...`"
|
||||
@keyup.enter="saveEmail()"
|
||||
/>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.changeEmailModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!email"
|
||||
@click="saveEmail()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
ref="managePasswordModal"
|
||||
:header="`${
|
||||
removePasswordMode ? 'Remove' : auth.user.has_password ? 'Change' : 'Add'
|
||||
} password`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<ul
|
||||
v-if="newPassword !== confirmNewPassword && confirmNewPassword.length > 0"
|
||||
class="known-errors"
|
||||
>
|
||||
<li>Input passwords do not match!</li>
|
||||
</ul>
|
||||
<label v-if="removePasswordMode" for="old-password">
|
||||
<span class="label__title">Confirm password</span>
|
||||
<span class="label__description">Please enter your password to proceed.</span>
|
||||
</label>
|
||||
<label v-else-if="auth.user.has_password" for="old-password">
|
||||
<span class="label__title">Old password</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="auth.user.has_password"
|
||||
id="old-password"
|
||||
v-model="oldPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="`${removePasswordMode ? 'Confirm' : 'Old'} password`"
|
||||
/>
|
||||
<template v-if="!removePasswordMode">
|
||||
<label for="new-password"><span class="label__title">New password</span></label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
<label for="confirm-new-password"
|
||||
><span class="label__title">Confirm new password</span></label
|
||||
>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</template>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.managePasswordModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<template v-if="removePasswordMode">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
:disabled="!oldPassword"
|
||||
@click="savePassword"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove password
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="auth.user.has_password && auth.user.auth_providers.length > 0"
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
@click="removePasswordMode = true"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="
|
||||
newPassword.length == 0 ||
|
||||
(auth.user.has_password && oldPassword.length == 0) ||
|
||||
newPassword !== confirmNewPassword
|
||||
"
|
||||
@click="savePassword"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save password
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
ref="manageTwoFactorModal"
|
||||
:header="`${
|
||||
auth.user.has_totp && twoFactorStep === 0 ? 'Remove' : 'Setup'
|
||||
} two-factor authentication`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<template v-if="auth.user.has_totp && twoFactorStep === 0">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">Enter two-factor code</span>
|
||||
<span class="label__description">Please enter a two-factor code to proceed.</span>
|
||||
</label>
|
||||
<input
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCode"
|
||||
maxlength="11"
|
||||
type="text"
|
||||
placeholder="Enter code..."
|
||||
@keyup.enter="removeTwoFactor()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.manageTwoFactorModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button danger-button" @click="removeTwoFactor">
|
||||
<TrashIcon />
|
||||
Remove 2FA
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="twoFactorStep === 0">
|
||||
<p>
|
||||
Two-factor authentication keeps your account secure by requiring access to a second
|
||||
device in order to sign in.
|
||||
<br /><br />
|
||||
Scan the QR code with <a href="https://authy.com/">Authy</a>,
|
||||
<a href="https://www.microsoft.com/en-us/security/mobile-authenticator-app">
|
||||
Microsoft Authenticator</a
|
||||
>, or any other 2FA app to begin.
|
||||
</p>
|
||||
<qrcode-vue
|
||||
v-if="twoFactorSecret"
|
||||
:value="`otpauth://totp/${encodeURIComponent(
|
||||
auth.user.email
|
||||
)}?secret=${twoFactorSecret}&issuer=Modrinth`"
|
||||
:size="250"
|
||||
:margin="2"
|
||||
level="H"
|
||||
/>
|
||||
<p>
|
||||
If the QR code does not scan, you can manually enter the secret:
|
||||
<strong>{{ twoFactorSecret }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 1">
|
||||
<label for="verify-code">
|
||||
<span class="label__title">Verify code</span>
|
||||
<span class="label__description"
|
||||
>Enter the one-time code from authenticator to verify access.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="verify-code"
|
||||
v-model="twoFactorCode"
|
||||
maxlength="6"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="Enter code..."
|
||||
@keyup.enter="verifyTwoFactorCode()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 2">
|
||||
<p>
|
||||
Download and save these back-up codes in a safe place. You can use these in-place of a
|
||||
2FA code if you ever lose access to your device! You should protect these codes like
|
||||
your password.
|
||||
</p>
|
||||
<p>Backup codes can only be used once.</p>
|
||||
<ul>
|
||||
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div class="input-group push-right">
|
||||
<button v-if="twoFactorStep === 1" class="iconified-button" @click="twoFactorStep = 0">
|
||||
<LeftArrowIcon />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep !== 2"
|
||||
class="iconified-button"
|
||||
@click="$refs.manageTwoFactorModal.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep <= 1"
|
||||
class="iconified-button brand-button"
|
||||
@click="twoFactorStep === 1 ? verifyTwoFactorCode() : (twoFactorStep = 1)"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep === 2"
|
||||
class="iconified-button brand-button"
|
||||
@click="$refs.manageTwoFactorModal.hide()"
|
||||
>
|
||||
<CheckIcon />
|
||||
Complete setup
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal ref="manageProvidersModal" header="Authentication providers">
|
||||
<div class="universal-modal">
|
||||
<div class="table">
|
||||
<div class="table-row table-head">
|
||||
<div class="table-cell table-text">Provider</div>
|
||||
<div class="table-cell table-text">Actions</div>
|
||||
</div>
|
||||
<div v-for="provider in authProviders" :key="provider.id" class="table-row">
|
||||
<div class="table-cell table-text">
|
||||
<span><component :is="provider.icon" /> {{ provider.display }}</span>
|
||||
</div>
|
||||
<div class="table-cell table-text manage">
|
||||
<button
|
||||
v-if="auth.user.auth_providers.includes(provider.id)"
|
||||
class="btn"
|
||||
@click="removeAuthProvider(provider.id)"
|
||||
>
|
||||
<TrashIcon /> Remove
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
class="btn"
|
||||
:href="`${getAuthUrl(provider.id, '/settings/account')}&token=${auth.token}`"
|
||||
>
|
||||
<ExternalIcon /> Add
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.manageProvidersModal.hide()">
|
||||
<XIcon />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<section class="universal-card">
|
||||
<h2>Account security</h2>
|
||||
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Email</span>
|
||||
<span class="label__description">Changes the email associated with your account.</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="$refs.changeEmailModal.show()">
|
||||
<template v-if="auth.user.email">
|
||||
<EditIcon />
|
||||
Change email
|
||||
</template>
|
||||
<template v-else>
|
||||
<PlusIcon />
|
||||
Add email
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Password</span>
|
||||
<span v-if="auth.user.has_password" class="label__description">
|
||||
Change <template v-if="auth.user.auth_providers.length > 0">or remove</template> the
|
||||
password used to login to your account.
|
||||
</span>
|
||||
<span v-else class="label__description">
|
||||
Set a permanent password to login to your account.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
class="iconified-button"
|
||||
@click="
|
||||
() => {
|
||||
oldPassword = ''
|
||||
newPassword = ''
|
||||
confirmNewPassword = ''
|
||||
removePasswordMode = false
|
||||
$refs.managePasswordModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<KeyIcon />
|
||||
<template v-if="auth.user.has_password"> Change password </template>
|
||||
<template v-else> Add password </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Two-factor authentication</span>
|
||||
<span class="label__description">
|
||||
Add an additional layer of security to your account during login.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="showTwoFactorModal">
|
||||
<template v-if="auth.user.has_totp"> <TrashIcon /> Remove 2FA </template>
|
||||
<template v-else> <PlusIcon /> Setup 2FA </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Manage authentication providers</span>
|
||||
<span class="label__description">
|
||||
Add or remove sign-on methods from your account, including GitHub, GitLab, Microsoft,
|
||||
Discord, Steam, and Google.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="$refs.manageProvidersModal.show()">
|
||||
<SettingsIcon /> Manage providers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="delete-account" class="universal-card">
|
||||
<h2>Delete account</h2>
|
||||
<p>
|
||||
Once you delete your account, there is no going back. Deleting your account will remove all
|
||||
attached data, excluding projects, from our servers.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
@click="$refs.modal_confirm.show()"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete account
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
CheckIcon,
|
||||
ExternalIcon,
|
||||
} from '@modrinth/assets'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import GitHubIcon from 'assets/icons/auth/sso-github.svg'
|
||||
import MicrosoftIcon from 'assets/icons/auth/sso-microsoft.svg'
|
||||
import GoogleIcon from 'assets/icons/auth/sso-google.svg'
|
||||
import SteamIcon from 'assets/icons/auth/sso-steam.svg'
|
||||
import DiscordIcon from 'assets/icons/auth/sso-discord.svg'
|
||||
import KeyIcon from 'assets/icons/auth/key.svg'
|
||||
import GitLabIcon from 'assets/icons/auth/sso-gitlab.svg'
|
||||
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Account settings - Modrinth',
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const auth = await useAuth()
|
||||
|
||||
const changeEmailModal = ref()
|
||||
const email = ref(auth.value.user.email)
|
||||
async function saveEmail() {
|
||||
if (!email.value) {
|
||||
return
|
||||
}
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`auth/email`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
email: email.value,
|
||||
},
|
||||
})
|
||||
changeEmailModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const managePasswordModal = ref()
|
||||
const removePasswordMode = ref(false)
|
||||
const oldPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmNewPassword = ref('')
|
||||
async function savePassword() {
|
||||
if (newPassword.value !== confirmNewPassword.value) {
|
||||
return
|
||||
}
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`auth/password`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
old_password: auth.value.user.has_password ? oldPassword.value : null,
|
||||
new_password: removePasswordMode.value ? null : newPassword.value,
|
||||
},
|
||||
})
|
||||
managePasswordModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const manageTwoFactorModal = ref()
|
||||
const twoFactorSecret = ref(null)
|
||||
const twoFactorFlow = ref(null)
|
||||
const twoFactorStep = ref(0)
|
||||
async function showTwoFactorModal() {
|
||||
twoFactorStep.value = 0
|
||||
twoFactorCode.value = null
|
||||
twoFactorIncorrect.value = false
|
||||
if (auth.value.user.has_totp) {
|
||||
manageTwoFactorModal.value.show()
|
||||
return
|
||||
}
|
||||
|
||||
twoFactorSecret.value = null
|
||||
twoFactorFlow.value = null
|
||||
backupCodes.value = []
|
||||
manageTwoFactorModal.value.show()
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/2fa/get_secret', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
twoFactorSecret.value = res.secret
|
||||
twoFactorFlow.value = res.flow
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const twoFactorIncorrect = ref(false)
|
||||
const twoFactorCode = ref(null)
|
||||
const backupCodes = ref([])
|
||||
async function verifyTwoFactorCode() {
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/2fa', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: twoFactorCode.value ? twoFactorCode.value : '',
|
||||
flow: twoFactorFlow.value,
|
||||
},
|
||||
})
|
||||
|
||||
backupCodes.value = res.backup_codes
|
||||
twoFactorStep.value = 2
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
twoFactorIncorrect.value = true
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removeTwoFactor() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch('auth/2fa', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
code: twoFactorCode.value ? twoFactorCode.value.toString() : '',
|
||||
},
|
||||
})
|
||||
manageTwoFactorModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
twoFactorIncorrect.value = true
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const authProviders = [
|
||||
{
|
||||
id: 'github',
|
||||
display: 'GitHub',
|
||||
icon: GitHubIcon,
|
||||
},
|
||||
{
|
||||
id: 'gitlab',
|
||||
display: 'GitLab',
|
||||
icon: GitLabIcon,
|
||||
},
|
||||
{
|
||||
id: 'steam',
|
||||
display: 'Steam',
|
||||
icon: SteamIcon,
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
display: 'Discord',
|
||||
icon: DiscordIcon,
|
||||
},
|
||||
{
|
||||
id: 'microsoft',
|
||||
display: 'Microsoft',
|
||||
icon: MicrosoftIcon,
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
display: 'Google',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
]
|
||||
|
||||
async function deleteAccount() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
useCookie('auth-token').value = null
|
||||
window.location.href = '/'
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
canvas {
|
||||
margin: 0 auto;
|
||||
border-radius: var(--size-rounded-card);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
grid-template-columns: 1fr 10rem;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto 0;
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
568
apps/frontend/src/pages/settings/applications.vue
Normal file
568
apps/frontend/src/pages/settings/applications.vue
Normal file
@@ -0,0 +1,568 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete this application?"
|
||||
description="This will permanently delete this application and revoke all access tokens. (forever!)"
|
||||
proceed-label="Delete this application"
|
||||
@proceed="removeApp(editingId)"
|
||||
/>
|
||||
<Modal ref="appModal" header="Application information">
|
||||
<div class="universal-modal">
|
||||
<label for="app-name"><span class="label__title">Name</span> </label>
|
||||
<input
|
||||
id="app-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's name..."
|
||||
/>
|
||||
<label v-if="editingId" for="app-icon"><span class="label__title">Icon</span> </label>
|
||||
<div v-if="editingId" class="icon-submission">
|
||||
<Avatar size="md" :src="icon" />
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
class="btn"
|
||||
prompt="Upload icon"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="onImageSelection"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
</div>
|
||||
<label v-if="editingId" for="app-url">
|
||||
<span class="label__title">URL</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="editingId"
|
||||
id="app-url"
|
||||
v-model="url"
|
||||
maxlength="255"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<label v-if="editingId" for="app-description">
|
||||
<span class="label__title">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="editingId"
|
||||
id="app-description"
|
||||
v-model="description"
|
||||
class="description-textarea"
|
||||
maxlength="255"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's description..."
|
||||
/>
|
||||
<label for="app-scopes"><span class="label__title">Scopes</span> </label>
|
||||
<div id="app-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="app-redirect-uris"><span class="label__title">Redirect uris</span> </label>
|
||||
<div class="uri-input-list">
|
||||
<div v-for="(_, index) in redirectUris" :key="index">
|
||||
<div class="input-group url-input-group-fixes">
|
||||
<input
|
||||
v-model="redirectUris[index]"
|
||||
maxlength="2048"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com/auth/callback"
|
||||
/>
|
||||
<Button v-if="index !== 0" icon-only @click="() => redirectUris.splice(index, 1)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="index === 0"
|
||||
color="primary"
|
||||
icon-only
|
||||
@click="() => redirectUris.push('')"
|
||||
>
|
||||
<PlusIcon /> Add more
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="redirectUris.length <= 0">
|
||||
<Button color="primary" icon-only @click="() => redirectUris.push('')">
|
||||
<PlusIcon /> Add a redirect uri
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-row input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.appModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="editingId"
|
||||
:disabled="!canSubmit"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="editApp"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="!canSubmit"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="createApp"
|
||||
>
|
||||
<PlusIcon />
|
||||
Create App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2>{{ formatMessage(commonSettingsMessages.applications) }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
() => {
|
||||
name = null
|
||||
icon = null
|
||||
scopesVal = 0
|
||||
redirectUris = ['']
|
||||
editingId = null
|
||||
expires = null
|
||||
$refs.appModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> New Application
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Applications can be used to authenticate Modrinth's users with your products. For more
|
||||
information, see
|
||||
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>.
|
||||
</p>
|
||||
<div v-for="app in usersApps" :key="app.id" class="universal-card recessed token">
|
||||
<div class="token-info">
|
||||
<div class="token-icon">
|
||||
<Avatar size="sm" :src="app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">{{ app.name }}</h2>
|
||||
<div>Created on {{ new Date(app.created).toLocaleDateString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="token-information">
|
||||
<span class="label__title">About</span>
|
||||
</label>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
Client ID
|
||||
<CopyCode :text="app.id" />
|
||||
</div>
|
||||
<div v-if="!!clientCreatedInState(app.id)">
|
||||
<div>
|
||||
Client Secret <CopyCode :text="clientCreatedInState(app.id)?.client_secret" />
|
||||
</div>
|
||||
<div class="secret_disclaimer">
|
||||
<i> Save your secret now, it will be hidden after you leave this page! </i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<Button
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
setForm({
|
||||
...app,
|
||||
redirect_uris: app.redirect_uris.map((u) => u.uri) || [],
|
||||
})
|
||||
$refs.appModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
editingId = app.id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { UploadIcon, PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from '@modrinth/assets'
|
||||
import { CopyCode, ConfirmModal, Button, Checkbox, Avatar, FileInput } from '@modrinth/ui'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
import {
|
||||
scopeList,
|
||||
hasScope,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
getScopeValue,
|
||||
} from '~/composables/auth/scopes.ts'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Applications - Modrinth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { scopesToLabels } = useScopes()
|
||||
|
||||
const appModal = ref()
|
||||
|
||||
// Any apps created in the current state will be stored here
|
||||
// Users can copy Client Secrets and such before the page reloads
|
||||
const createdApps = ref([])
|
||||
|
||||
const editingId = ref(null)
|
||||
const name = ref(null)
|
||||
const icon = ref(null)
|
||||
const scopesVal = ref(BigInt(0))
|
||||
const redirectUris = ref([''])
|
||||
const url = ref(null)
|
||||
const description = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: usersApps, refresh } = await useAsyncData(
|
||||
'usersApps',
|
||||
() =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/oauth_apps`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
{
|
||||
watch: [auth],
|
||||
}
|
||||
)
|
||||
|
||||
const setForm = (app) => {
|
||||
if (app?.id) {
|
||||
editingId.value = app.id
|
||||
} else {
|
||||
editingId.value = null
|
||||
}
|
||||
name.value = app?.name || ''
|
||||
icon.value = app?.icon_url || ''
|
||||
scopesVal.value = app?.max_scopes || BigInt(0)
|
||||
url.value = app?.url || ''
|
||||
description.value = app?.description || ''
|
||||
|
||||
if (app?.redirect_uris) {
|
||||
redirectUris.value = app.redirect_uris.map((uri) => uri?.uri || uri)
|
||||
} else {
|
||||
redirectUris.value = ['']
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
// Make sure name, scopes, and return uri are at least filled in
|
||||
const filledIn =
|
||||
name.value && name.value !== '' && name.value?.length > 2 && redirectUris.value.length > 0
|
||||
// Make sure the redirect uris are either one empty string or all filled in with valid urls
|
||||
const oneValid = redirectUris.value.length === 1 && redirectUris.value[0] === ''
|
||||
let allValid
|
||||
try {
|
||||
allValid = redirectUris.value.every((uri) => {
|
||||
const url = new URL(uri)
|
||||
return !!url
|
||||
})
|
||||
} catch (err) {
|
||||
allValid = false
|
||||
}
|
||||
return filledIn && (oneValid || allValid)
|
||||
})
|
||||
|
||||
const clientCreatedInState = (id) => {
|
||||
return createdApps.value.find((app) => app.id === id)
|
||||
}
|
||||
|
||||
async function onImageSelection(files) {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
const extFromType = file.type.split('/')[1]
|
||||
|
||||
await useBaseFetch('oauth/app/' + editingId.value + '/icon', {
|
||||
method: 'PATCH',
|
||||
internal: true,
|
||||
body: file,
|
||||
query: {
|
||||
ext: extFromType,
|
||||
},
|
||||
})
|
||||
|
||||
await refresh()
|
||||
|
||||
const app = usersApps.value.find((app) => app.id === editingId.value)
|
||||
if (app) {
|
||||
setForm(app)
|
||||
}
|
||||
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'Icon updated',
|
||||
text: 'Your application icon has been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
const createdAppInfo = await useBaseFetch('oauth/app', {
|
||||
method: 'POST',
|
||||
internal: true,
|
||||
body: {
|
||||
name: name.value,
|
||||
icon_url: icon.value,
|
||||
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
|
||||
redirect_uris: redirectUris.value,
|
||||
},
|
||||
})
|
||||
|
||||
createdApps.value.push(createdAppInfo)
|
||||
|
||||
setForm(null)
|
||||
appModal.value.hide()
|
||||
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function editApp() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
|
||||
// check if there's any difference between the current app and the one in the state
|
||||
const app = usersApps.value.find((app) => app.id === editingId.value)
|
||||
if (!app) {
|
||||
throw new Error('No app found')
|
||||
}
|
||||
|
||||
if (
|
||||
app.name === name.value &&
|
||||
app.icon_url === icon.value &&
|
||||
app.max_scopes === scopesVal.value &&
|
||||
app.redirect_uris === redirectUris.value &&
|
||||
app.url === url.value &&
|
||||
app.description === description.value
|
||||
) {
|
||||
setForm(null)
|
||||
editingId.value = null
|
||||
appModal.value.hide()
|
||||
throw new Error('No changes detected')
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: name.value,
|
||||
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
|
||||
redirect_uris: redirectUris.value,
|
||||
}
|
||||
|
||||
if (url.value && url.value?.length > 0) {
|
||||
body.url = url.value
|
||||
}
|
||||
|
||||
if (description.value && description.value?.length > 0) {
|
||||
body.description = description.value
|
||||
}
|
||||
|
||||
if (icon.value && icon.value?.length > 0) {
|
||||
body.icon_url = icon.value
|
||||
}
|
||||
|
||||
await useBaseFetch('oauth/app/' + editingId.value, {
|
||||
method: 'PATCH',
|
||||
internal: true,
|
||||
body,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
setForm(null)
|
||||
editingId.value = null
|
||||
|
||||
appModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removeApp() {
|
||||
startLoading()
|
||||
try {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
await useBaseFetch(`oauth/app/${editingId.value}`, {
|
||||
internal: true,
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
editingId.value = null
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.description-textarea {
|
||||
height: 6rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.secret_disclaimer {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.submit-row {
|
||||
padding-top: var(--gap-lg);
|
||||
}
|
||||
.uri-input-list {
|
||||
display: grid;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
.url-input-group-fixes {
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100% !important;
|
||||
flex-basis: 24rem !important;
|
||||
}
|
||||
}
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-submission {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.token-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-content {
|
||||
display: grid;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.token-icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-heading {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-gray-700);
|
||||
|
||||
margin-top: var(--spacing-card-md);
|
||||
margin-bottom: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
|
||||
// For the children override the padding so that y padding is --gap-sm and x padding is --gap-lg
|
||||
// Knossos global styling breaks everything
|
||||
> * {
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
245
apps/frontend/src/pages/settings/authorizations.vue
Normal file
245
apps/frontend/src/pages/settings/authorizations.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<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>{{ 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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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 { Button, ConfirmModal, Avatar } from '@modrinth/ui'
|
||||
import { TrashIcon, CheckIcon } from '@modrinth/assets'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
import { useScopes } from '~/composables/auth/scopes.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { scopesToDefinitions } = useScopes()
|
||||
|
||||
const revokingId = ref(null)
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Authorizations - Modrinth',
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: appCreatorsInformation } = await useAsyncData(
|
||||
'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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function revokeApp(id) {
|
||||
try {
|
||||
await useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
method: 'DELETE',
|
||||
query: {
|
||||
client_id: id,
|
||||
},
|
||||
})
|
||||
revokingId.value = null
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-list {
|
||||
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;
|
||||
|
||||
// avoid breaking text or overflowing
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-name {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-content {
|
||||
width: 100%;
|
||||
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
646
apps/frontend/src/pages/settings/index.vue
Normal file
646
apps/frontend/src/pages/settings/index.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<template>
|
||||
<div>
|
||||
<MessageBanner v-if="flags.developerMode" message-type="warning" class="developer-message">
|
||||
<CodeIcon />
|
||||
<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>{{ formatMessage(colorTheme.title) }}</h2>
|
||||
<p>{{ formatMessage(colorTheme.description) }}</p>
|
||||
<div class="theme-options">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option"
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: theme.preference === option }"
|
||||
@click="() => updateColorTheme(option)"
|
||||
>
|
||||
<div class="preview" :class="`${option === 'system' ? systemTheme : option}-mode`">
|
||||
<div class="example-card card card">
|
||||
<div class="example-icon"></div>
|
||||
<div class="example-text-1"></div>
|
||||
<div class="example-text-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked v-if="theme.preference === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
|
||||
<SunIcon
|
||||
v-if="'light' === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredLight)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
<MoonIcon
|
||||
v-else-if="(cosmetics.preferredDarkTheme ?? 'dark') === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredDark)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2>{{ formatMessage(projectListLayouts.title) }}</h2>
|
||||
<p>{{ 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">
|
||||
<RadioButtonChecked
|
||||
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">
|
||||
<RadioButtonChecked
|
||||
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">
|
||||
<RadioButtonChecked
|
||||
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>{{ formatMessage(toggleFeatures.title) }}</h2>
|
||||
<p>{{ 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"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</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"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
<div 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"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="search-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="search-layout-toggle"
|
||||
v-model="cosmetics.searchLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="project-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-layout-toggle"
|
||||
v-model="cosmetics.projectLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CodeIcon, RadioButtonIcon, RadioButtonChecked, SunIcon, MoonIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import { formatProjectType } from '~/plugins/shorthands.js'
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import { DARK_THEMES } from '~/composables/theme.js'
|
||||
|
||||
useHead({
|
||||
title: 'Display settings - Modrinth',
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
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.',
|
||||
},
|
||||
system: {
|
||||
id: 'settings.display.theme.system',
|
||||
defaultMessage: 'Sync with system',
|
||||
},
|
||||
light: {
|
||||
id: 'settings.display.theme.light',
|
||||
defaultMessage: 'Light',
|
||||
},
|
||||
dark: {
|
||||
id: 'settings.display.theme.dark',
|
||||
defaultMessage: 'Dark',
|
||||
},
|
||||
oled: {
|
||||
id: 'settings.display.theme.oled',
|
||||
defaultMessage: 'OLED',
|
||||
},
|
||||
retro: {
|
||||
id: 'settings.display.theme.retro',
|
||||
defaultMessage: 'Retro',
|
||||
},
|
||||
preferredLight: {
|
||||
id: 'settings.display.theme.preferred-light-theme',
|
||||
defaultMessage: 'Preferred light theme',
|
||||
},
|
||||
preferredDark: {
|
||||
id: 'settings.display.theme.preferred-dark-theme',
|
||||
defaultMessage: 'Preferred dark theme',
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
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.',
|
||||
},
|
||||
rightAlignedSearchSidebarTitle: {
|
||||
id: 'settings.display.sidebar.right-aligned-search-sidebar.title',
|
||||
defaultMessage: 'Right-aligned search sidebar',
|
||||
},
|
||||
rightAlignedSearchSidebarDescription: {
|
||||
id: 'settings.display.sidebar.right-aligned-search-sidebar.description',
|
||||
defaultMessage: 'Aligns the search filters sidebar to the right of the search results.',
|
||||
},
|
||||
rightAlignedProjectSidebarTitle: {
|
||||
id: 'settings.display.sidebar.right-aligned-project-sidebar.title',
|
||||
defaultMessage: 'Right-aligned project sidebar',
|
||||
},
|
||||
rightAlignedProjectSidebarDescription: {
|
||||
id: 'settings.display.sidebar.right-aligned-project-sidebar.description',
|
||||
defaultMessage: "Aligns the project details sidebar to the right of the page's content.",
|
||||
},
|
||||
})
|
||||
|
||||
const cosmetics = useCosmetics()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useTags()
|
||||
|
||||
const systemTheme = ref('light')
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
const themeOptions = computed(() => {
|
||||
const options = ['system', 'light', 'dark', 'oled']
|
||||
if (flags.value.developerMode || theme.value.preference === 'retro') {
|
||||
options.push('retro')
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateSystemTheme()
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (event) => {
|
||||
setSystemTheme(event.matches)
|
||||
})
|
||||
})
|
||||
|
||||
function updateSystemTheme() {
|
||||
const query = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setSystemTheme(query.matches)
|
||||
}
|
||||
|
||||
function setSystemTheme(light) {
|
||||
if (light) {
|
||||
systemTheme.value = 'light'
|
||||
} else {
|
||||
systemTheme.value = cosmetics.value.preferredDarkTheme ?? 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
function updateColorTheme(value) {
|
||||
if (DARK_THEMES.includes(value)) {
|
||||
cosmetics.value.preferredDarkTheme = value
|
||||
saveCosmetics()
|
||||
updateSystemTheme()
|
||||
}
|
||||
updateTheme(value, true)
|
||||
}
|
||||
|
||||
function disableDeveloperMode() {
|
||||
flags.value.developerMode = !flags.value.developerMode
|
||||
saveFeatureFlags()
|
||||
addNotification({
|
||||
group: 'main',
|
||||
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,
|
||||
name: formatProjectType(type.id) + 's',
|
||||
display: 'the ' + formatProjectType(type.id).toLowerCase() + 's search page',
|
||||
}
|
||||
})
|
||||
types.push({
|
||||
id: 'user',
|
||||
name: 'User profiles',
|
||||
display: 'user pages',
|
||||
})
|
||||
return types
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.preview-radio {
|
||||
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-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
|
||||
.label {
|
||||
.radio {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
background-color: var(--color-bg);
|
||||
padding: 1.5rem;
|
||||
outline: 2px solid transparent;
|
||||
width: 100%;
|
||||
|
||||
.example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
outline: 2px solid transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
.radio {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.preview .example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template: 'icon text1' 'icon text2';
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
.example-icon {
|
||||
grid-area: icon;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1,
|
||||
.example-text-2 {
|
||||
height: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1 {
|
||||
grid-area: text1;
|
||||
width: 100%;
|
||||
background-color: var(--color-base);
|
||||
}
|
||||
|
||||
.example-text-2 {
|
||||
grid-area: text2;
|
||||
width: 60%;
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
|
||||
> :first-child .label__title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
.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);
|
||||
|
||||
.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);
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.developer-message {
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
margin-bottom: 2px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: var(--gap-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
527
apps/frontend/src/pages/settings/language.vue
Normal file
527
apps/frontend/src/pages/settings/language.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<script setup lang="ts">
|
||||
import Fuse from 'fuse.js/dist/fuse.basic'
|
||||
import RadioButtonIcon from '~/assets/images/utils/radio-button.svg?component'
|
||||
import RadioButtonCheckedIcon from '~/assets/images/utils/radio-button-checked.svg?component'
|
||||
import WarningIcon from '~/assets/images/utils/issues.svg?component'
|
||||
import { isModifierKeyDown } from '~/helpers/events.ts'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.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',
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
type Category = keyof typeof categoryNames
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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 (err) {
|
||||
$failedLocale.value = value
|
||||
} finally {
|
||||
$changingTo.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const $languagesList = ref<HTMLDivElement | undefined>()
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
|
||||
|
||||
const focusableTarget = $languagesList.value?.querySelector(
|
||||
'input, [tabindex]:not([tabindex="-1"])'
|
||||
) as HTMLElement | undefined
|
||||
|
||||
focusableTarget?.focus()
|
||||
}
|
||||
|
||||
function onItemKeydown(e: KeyboardEvent, locale: Locale) {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (isModifierKeyDown(e) || isChanging()) return
|
||||
|
||||
changeLocale(locale.tag)
|
||||
}
|
||||
|
||||
function onItemClick(e: MouseEvent, locale: Locale) {
|
||||
if (isModifierKeyDown(e) || isChanging()) return
|
||||
|
||||
changeLocale(locale.tag)
|
||||
}
|
||||
|
||||
function getItemLabel(locale: Locale) {
|
||||
const label = locale.auto
|
||||
? formatMessage(messages.automaticLocale)
|
||||
: `${locale.translatedName}. ${locale.displayName}`
|
||||
|
||||
if ($changingTo.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelApplying, { label })
|
||||
}
|
||||
|
||||
if ($failedLocale.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelError, { label })
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>{{ 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="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-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
|
||||
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" />
|
||||
|
||||
<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="$failedLocale === locale.tag"
|
||||
:id="`language__${locale.tag}__fail`"
|
||||
class="language-load-error"
|
||||
>
|
||||
<WarningIcon /> {{ formatMessage(messages.loadFailed) }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.languages-list {
|
||||
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;
|
||||
|
||||
&:not([aria-disabled='true']):hover {
|
||||
border-color: var(--color-button-bg-hover);
|
||||
}
|
||||
|
||||
&:focus-visible,
|
||||
&:has(:focus-visible) {
|
||||
outline: 2px solid var(--color-brand);
|
||||
}
|
||||
|
||||
&.errored {
|
||||
border-color: var(--color-red);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
}
|
||||
|
||||
&.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-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%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes shimmerSliding {
|
||||
from {
|
||||
left: -100%;
|
||||
}
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[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;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.language-names {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.language-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.language-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-link-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
</style>
|
||||
428
apps/frontend/src/pages/settings/pats.vue
Normal file
428
apps/frontend/src/pages/settings/pats.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<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="header__row">
|
||||
<div class="header__title">
|
||||
<h2>{{ 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 { PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from '@modrinth/assets'
|
||||
import { Checkbox, ConfirmModal } from '@modrinth/ui'
|
||||
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
import {
|
||||
hasScope,
|
||||
scopeList,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
getScopeValue,
|
||||
} from '~/composables/auth/scopes.ts'
|
||||
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
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}',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: `${formatMessage(commonSettingsMessages.pats)} - Modrinth`,
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { scopesToLabels } = useScopes()
|
||||
const patModal = ref()
|
||||
|
||||
const editPatIndex = ref(null)
|
||||
|
||||
const name = ref(null)
|
||||
const scopesVal = ref(BigInt(0))
|
||||
const expires = ref(null)
|
||||
|
||||
const deletePatIndex = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const { data: pats, refresh } = await useAsyncData('pat', () => useBaseFetch('pat'))
|
||||
|
||||
async function createPat() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await useBaseFetch('pat', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
})
|
||||
pats.value.push(res)
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
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) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
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) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
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;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
apps/frontend/src/pages/settings/profile.vue
Normal file
234
apps/frontend/src/pages/settings/profile.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="card">
|
||||
<h2>{{ formatMessage(messages.title) }}</h2>
|
||||
<p>
|
||||
<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="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 { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, FileInput, Button } from '@modrinth/ui'
|
||||
import { commonMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
useHead({
|
||||
title: 'Profile settings - Modrinth',
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
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.',
|
||||
},
|
||||
})
|
||||
|
||||
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 saved = ref(false)
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
username.value = auth.value.user.username
|
||||
bio.value = auth.value.user.bio
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
startLoading()
|
||||
try {
|
||||
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 = {}
|
||||
|
||||
if (auth.value.user.username !== username.value) {
|
||||
body.username = username.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({
|
||||
group: 'main',
|
||||
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);
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 6rem;
|
||||
width: 40rem;
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
</style>
|
||||
144
apps/frontend/src/pages/settings/sessions.vue
Normal file
144
apps/frontend/src/pages/settings/sessions.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<h2>{{ 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">
|
||||
<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 { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(commonSettingsMessages.sessions)} - Modrinth`,
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { data: sessions, refresh } = await useAsyncData('session/list', () =>
|
||||
useBaseFetch('session/list')
|
||||
)
|
||||
|
||||
async function revokeSession(id) {
|
||||
startLoading()
|
||||
try {
|
||||
sessions.value = sessions.value.filter((x) => x.id !== id)
|
||||
await useBaseFetch(`session/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user