You've already forked AstralRinth
forked from didirus/AstralRinth
refactor: migrate to common eslint+prettier configs (#4168)
* refactor: migrate to common eslint+prettier configs * fix: prettier frontend * feat: config changes * fix: lint issues * fix: lint * fix: type imports * fix: cyclical import issue * fix: lockfile * fix: missing dep * fix: switch to tabs * fix: continue switch to tabs * fix: rustfmt parity * fix: moderation lint issue * fix: lint issues * fix: ui intl * fix: lint issues * Revert "fix: rustfmt parity" This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711. * feat: revert last rs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,250 +1,251 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to revoke this application?"
|
||||
description="This will revoke the application's access to your account. You can always re-authorize it later."
|
||||
proceed-label="Revoke"
|
||||
@proceed="revokeApp(revokingId)"
|
||||
/>
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.authorizedApps) }}</h2>
|
||||
<p>
|
||||
When you authorize an application with your Modrinth account, you grant it access to your
|
||||
account. You can manage and review access to your account here at any time.
|
||||
</p>
|
||||
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
|
||||
You have not authorized any applications.
|
||||
</div>
|
||||
<div
|
||||
v-for="authorization in appInfoLookup"
|
||||
:key="authorization.id"
|
||||
class="universal-card recessed token mt-4"
|
||||
>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
<div class="icon-name">
|
||||
<Avatar :src="authorization.app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">
|
||||
{{ authorization.app.name }}
|
||||
</h2>
|
||||
<div>
|
||||
by
|
||||
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
|
||||
authorization.owner.username
|
||||
}}</nuxt-link>
|
||||
<template v-if="authorization.app.url">
|
||||
<span> ⋅ </span>
|
||||
<nuxt-link class="text-link" :to="authorization.app.url">
|
||||
{{ authorization.app.url }}
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="authorization.app.description">
|
||||
<label for="app-description">
|
||||
<span class="label__title"> About this app </span>
|
||||
</label>
|
||||
<div id="app-description">{{ authorization.app.description }}</div>
|
||||
</template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to revoke this application?"
|
||||
description="This will revoke the application's access to your account. You can always re-authorize it later."
|
||||
proceed-label="Revoke"
|
||||
@proceed="revokeApp(revokingId)"
|
||||
/>
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.authorizedApps) }}</h2>
|
||||
<p>
|
||||
When you authorize an application with your Modrinth account, you grant it access to your
|
||||
account. You can manage and review access to your account here at any time.
|
||||
</p>
|
||||
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
|
||||
You have not authorized any applications.
|
||||
</div>
|
||||
<div
|
||||
v-for="authorization in appInfoLookup"
|
||||
:key="authorization.id"
|
||||
class="universal-card recessed token mt-4"
|
||||
>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
<div class="icon-name">
|
||||
<Avatar :src="authorization.app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">
|
||||
{{ authorization.app.name }}
|
||||
</h2>
|
||||
<div>
|
||||
by
|
||||
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
|
||||
authorization.owner.username
|
||||
}}</nuxt-link>
|
||||
<template v-if="authorization.app.url">
|
||||
<span> ⋅ </span>
|
||||
<nuxt-link class="text-link" :to="authorization.app.url">
|
||||
{{ authorization.app.url }}
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="authorization.app.description">
|
||||
<label for="app-description">
|
||||
<span class="label__title"> About this app </span>
|
||||
</label>
|
||||
<div id="app-description">{{ authorization.app.description }}</div>
|
||||
</template>
|
||||
|
||||
<label for="app-scope-list">
|
||||
<span class="label__title">Scopes</span>
|
||||
</label>
|
||||
<div class="scope-list">
|
||||
<div
|
||||
v-for="scope in scopesToDefinitions(authorization.scopes)"
|
||||
:key="scope"
|
||||
class="scope-list-item"
|
||||
>
|
||||
<div class="scope-list-item-icon">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
{{ scope }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="app-scope-list">
|
||||
<span class="label__title">Scopes</span>
|
||||
</label>
|
||||
<div class="scope-list">
|
||||
<div
|
||||
v-for="scope in scopesToDefinitions(authorization.scopes)"
|
||||
:key="scope"
|
||||
class="scope-list-item"
|
||||
>
|
||||
<div class="scope-list-item-icon">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
{{ scope }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<Button
|
||||
color="danger"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
revokingId = authorization.app_id;
|
||||
$refs.modal_confirm.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<Button
|
||||
color="danger"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
revokingId = authorization.app_id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { CheckIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { CheckIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
commonSettingsMessages,
|
||||
ConfirmModal,
|
||||
injectNotificationManager,
|
||||
} from "@modrinth/ui";
|
||||
import { useScopes } from "~/composables/auth/scopes.ts";
|
||||
Avatar,
|
||||
Button,
|
||||
commonSettingsMessages,
|
||||
ConfirmModal,
|
||||
injectNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const { formatMessage } = useVIntl();
|
||||
import { useScopes } from '~/composables/auth/scopes.ts'
|
||||
|
||||
const { scopesToDefinitions } = useScopes();
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const revokingId = ref(null);
|
||||
const { scopesToDefinitions } = useScopes()
|
||||
|
||||
const revokingId = ref(null)
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: "Authorizations - Modrinth",
|
||||
});
|
||||
title: 'Authorizations - Modrinth',
|
||||
})
|
||||
|
||||
const { data: usersApps, refresh } = await useAsyncData("userAuthorizations", () =>
|
||||
useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
}),
|
||||
);
|
||||
const { data: usersApps, refresh } = await useAsyncData('userAuthorizations', () =>
|
||||
useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const { data: appInformation } = await useAsyncData(
|
||||
"appInfo",
|
||||
() =>
|
||||
useBaseFetch("oauth/apps", {
|
||||
internal: true,
|
||||
query: {
|
||||
ids: usersApps.value.map((c) => c.app_id).join(","),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: usersApps,
|
||||
},
|
||||
);
|
||||
'appInfo',
|
||||
() =>
|
||||
useBaseFetch('oauth/apps', {
|
||||
internal: true,
|
||||
query: {
|
||||
ids: usersApps.value.map((c) => c.app_id).join(','),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: usersApps,
|
||||
},
|
||||
)
|
||||
|
||||
const { data: appCreatorsInformation } = await useAsyncData(
|
||||
"appCreatorsInfo",
|
||||
() =>
|
||||
useBaseFetch("users", {
|
||||
query: {
|
||||
ids: JSON.stringify(appInformation.value.map((c) => c.created_by)),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: appInformation,
|
||||
},
|
||||
);
|
||||
'appCreatorsInfo',
|
||||
() =>
|
||||
useBaseFetch('users', {
|
||||
query: {
|
||||
ids: JSON.stringify(appInformation.value.map((c) => c.created_by)),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: appInformation,
|
||||
},
|
||||
)
|
||||
|
||||
const appInfoLookup = computed(() => {
|
||||
return usersApps.value.map((app) => {
|
||||
const info = appInformation.value.find((c) => c.id === app.app_id);
|
||||
const owner = appCreatorsInformation.value.find((c) => c.id === info.created_by);
|
||||
return {
|
||||
...app,
|
||||
app: info || null,
|
||||
owner: owner || null,
|
||||
};
|
||||
});
|
||||
});
|
||||
return usersApps.value.map((app) => {
|
||||
const info = appInformation.value.find((c) => c.id === app.app_id)
|
||||
const owner = appCreatorsInformation.value.find((c) => c.id === info.created_by)
|
||||
return {
|
||||
...app,
|
||||
app: info || null,
|
||||
owner: owner || null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function revokeApp(id) {
|
||||
try {
|
||||
await useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
method: "DELETE",
|
||||
query: {
|
||||
client_id: id,
|
||||
},
|
||||
});
|
||||
revokingId.value = null;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: "An error occurred",
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
try {
|
||||
await useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
method: 'DELETE',
|
||||
query: {
|
||||
client_id: id,
|
||||
},
|
||||
})
|
||||
revokingId.value = null
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-group {
|
||||
// Overrides for omorphia compat
|
||||
> * {
|
||||
padding: var(--gap-sm) var(--gap-lg) !important;
|
||||
}
|
||||
// Overrides for omorphia compat
|
||||
> * {
|
||||
padding: var(--gap-sm) var(--gap-lg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: var(--gap-sm);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.scope-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-gray-200);
|
||||
color: var(--color-gray-700);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
.scope-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-gray-200);
|
||||
color: var(--color-gray-700);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
|
||||
// avoid breaking text or overflowing
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
// avoid breaking text or overflowing
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scope-list-item-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: 0 0 auto;
|
||||
.scope-list-item-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: 0 0 auto;
|
||||
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-green);
|
||||
color: var(--color-raised-bg);
|
||||
}
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-green);
|
||||
color: var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-name {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-content {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,70 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="card">
|
||||
<Breadcrumbs
|
||||
current-title="Past charges"
|
||||
:link-stack="[{ href: '/settings/billing', label: 'Billing and subscriptions' }]"
|
||||
/>
|
||||
<h2>Past charges</h2>
|
||||
<p>All of your past charges to your Modrinth account will be listed here:</p>
|
||||
<div
|
||||
v-for="charge in charges"
|
||||
:key="charge.id"
|
||||
class="universal-card recessed flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-bold text-primary">
|
||||
<template v-if="charge.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||
<template v-else-if="charge.product.metadata.type === 'pyro'">
|
||||
Modrinth Servers
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
<template v-if="charge.subscription_interval">
|
||||
{{ charge.subscription_interval }}
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
|
||||
⋅
|
||||
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<section class="card">
|
||||
<Breadcrumbs
|
||||
current-title="Past charges"
|
||||
:link-stack="[{ href: '/settings/billing', label: 'Billing and subscriptions' }]"
|
||||
/>
|
||||
<h2>Past charges</h2>
|
||||
<p>All of your past charges to your Modrinth account will be listed here:</p>
|
||||
<div
|
||||
v-for="charge in charges"
|
||||
:key="charge.id"
|
||||
class="universal-card recessed flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-bold text-primary">
|
||||
<template v-if="charge.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||
<template v-else-if="charge.product.metadata.type === 'pyro'">
|
||||
Modrinth Servers
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
<template v-if="charge.subscription_interval">
|
||||
{{ charge.subscription_interval }}
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
|
||||
⋅
|
||||
{{ $dayjs(charge.due).format('YYYY-MM-DD') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Badge } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { products } from "~/generated/state.json";
|
||||
import { Badge, Breadcrumbs } from '@modrinth/ui'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const vintl = useVIntl();
|
||||
const vintl = useVIntl()
|
||||
|
||||
const { data: charges } = await useAsyncData(
|
||||
"billing/payments",
|
||||
() => useBaseFetch("billing/payments", { internal: true }),
|
||||
{
|
||||
transform: (charges) => {
|
||||
return charges
|
||||
.filter((charge) => charge.status !== "open" && charge.status !== "cancelled")
|
||||
.map((charge) => {
|
||||
const product = products.find((product) =>
|
||||
product.prices.some((price) => price.id === charge.price_id),
|
||||
);
|
||||
'billing/payments',
|
||||
() => useBaseFetch('billing/payments', { internal: true }),
|
||||
{
|
||||
transform: (charges) => {
|
||||
return charges
|
||||
.filter((charge) => charge.status !== 'open' && charge.status !== 'cancelled')
|
||||
.map((charge) => {
|
||||
const product = products.find((product) =>
|
||||
product.prices.some((price) => price.id === charge.price_id),
|
||||
)
|
||||
|
||||
charge.product = product;
|
||||
charge.product = product
|
||||
|
||||
return charge;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
return charge
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,489 +1,496 @@
|
||||
<template>
|
||||
<div>
|
||||
<MessageBanner v-if="flags.developerMode" message-type="warning" class="developer-message">
|
||||
<CodeIcon class="inline-flex" />
|
||||
<IntlFormatted :message-id="developerModeBanner.description">
|
||||
<template #strong="{ children }">
|
||||
<strong>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</strong>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<Button :action="() => disableDeveloperMode()">
|
||||
{{ formatMessage(developerModeBanner.deactivate) }}
|
||||
</Button>
|
||||
</MessageBanner>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(colorTheme.title) }}</h2>
|
||||
<p>{{ formatMessage(colorTheme.description) }}</p>
|
||||
<ThemeSelector
|
||||
:update-color-theme="updateColorTheme"
|
||||
:current-theme="theme.preferred"
|
||||
:theme-options="themeOptions"
|
||||
:system-theme-color="systemTheme"
|
||||
/>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(projectListLayouts.title) }}</h2>
|
||||
<p class="mb-4">{{ formatMessage(projectListLayouts.description) }}</p>
|
||||
<div class="project-lists">
|
||||
<div v-for="projectType in listTypes" :key="projectType.id + '-project-list-layouts'">
|
||||
<div class="label">
|
||||
<div class="label__title">
|
||||
{{
|
||||
projectListLayouts[projectType.id]
|
||||
? formatMessage(projectListLayouts[projectType.id])
|
||||
: projectType.id
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-list-layouts">
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'list' }"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'list')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-list-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Rows
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'grid' }"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-grid-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Grid
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery' }"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-gallery-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Gallery
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(toggleFeatures.title) }}</h2>
|
||||
<p class="mb-4">{{ formatMessage(toggleFeatures.description) }}</p>
|
||||
<div class="adjacent-input small">
|
||||
<label for="advanced-rendering">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="advanced-rendering"
|
||||
v-model="cosmetics.advancedRendering"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="external-links-new-tab">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="external-links-new-tab"
|
||||
v-model="cosmetics.externalLinksNewTab"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="false" class="adjacent-input small">
|
||||
<label for="modrinth-app-promos">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="modrinth-app-promos"
|
||||
v-model="cosmetics.hideModrinthAppPromos"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="search-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="search-layout-toggle"
|
||||
v-model="cosmetics.rightSearchLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="project-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-layout-toggle"
|
||||
v-model="cosmetics.leftContentLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<MessageBanner v-if="flags.developerMode" message-type="warning" class="developer-message">
|
||||
<CodeIcon class="inline-flex" />
|
||||
<IntlFormatted :message-id="developerModeBanner.description">
|
||||
<template #strong="{ children }">
|
||||
<strong>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</strong>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<Button :action="() => disableDeveloperMode()">
|
||||
{{ formatMessage(developerModeBanner.deactivate) }}
|
||||
</Button>
|
||||
</MessageBanner>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(colorTheme.title) }}</h2>
|
||||
<p>{{ formatMessage(colorTheme.description) }}</p>
|
||||
<ThemeSelector
|
||||
:update-color-theme="updateColorTheme"
|
||||
:current-theme="theme.preferred"
|
||||
:theme-options="themeOptions"
|
||||
:system-theme-color="systemTheme"
|
||||
/>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(projectListLayouts.title) }}</h2>
|
||||
<p class="mb-4">{{ formatMessage(projectListLayouts.description) }}</p>
|
||||
<div class="project-lists">
|
||||
<div v-for="projectType in listTypes" :key="projectType.id + '-project-list-layouts'">
|
||||
<div class="label">
|
||||
<div class="label__title">
|
||||
{{
|
||||
projectListLayouts[projectType.id]
|
||||
? formatMessage(projectListLayouts[projectType.id])
|
||||
: projectType.id
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-list-layouts">
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{
|
||||
selected: cosmetics.searchDisplayMode[projectType.id] === 'list',
|
||||
}"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'list')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-list-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Rows
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{
|
||||
selected: cosmetics.searchDisplayMode[projectType.id] === 'grid',
|
||||
}"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-grid-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Grid
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{
|
||||
selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery',
|
||||
}"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-gallery-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Gallery
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(toggleFeatures.title) }}</h2>
|
||||
<p class="mb-4">{{ formatMessage(toggleFeatures.description) }}</p>
|
||||
<div class="adjacent-input small">
|
||||
<label for="advanced-rendering">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="advanced-rendering"
|
||||
v-model="cosmetics.advancedRendering"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="external-links-new-tab">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="external-links-new-tab"
|
||||
v-model="cosmetics.externalLinksNewTab"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="false" class="adjacent-input small">
|
||||
<label for="modrinth-app-promos">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="modrinth-app-promos"
|
||||
v-model="cosmetics.hideModrinthAppPromos"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="search-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="search-layout-toggle"
|
||||
v-model="cosmetics.rightSearchLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="project-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-layout-toggle"
|
||||
v-model="cosmetics.leftContentLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon, RadioButtonCheckedIcon, RadioButtonIcon } from "@modrinth/assets";
|
||||
import { Button, injectNotificationManager, ThemeSelector } from "@modrinth/ui";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import type { DisplayLocation } from "~/plugins/cosmetics";
|
||||
import { isDarkTheme, type Theme } from "~/plugins/theme/index.ts";
|
||||
import { CodeIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import { Button, injectNotificationManager, ThemeSelector } from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import type { DisplayLocation } from '~/plugins/cosmetics'
|
||||
import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts'
|
||||
|
||||
useHead({
|
||||
title: "Display settings - Modrinth",
|
||||
});
|
||||
title: 'Display settings - Modrinth',
|
||||
})
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const { formatMessage } = useVIntl();
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const developerModeBanner = defineMessages({
|
||||
description: {
|
||||
id: "settings.display.banner.developer-mode.description",
|
||||
defaultMessage:
|
||||
"<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode.",
|
||||
},
|
||||
deactivate: {
|
||||
id: "settings.display.banner.developer-mode.button",
|
||||
defaultMessage: "Deactivate developer mode",
|
||||
},
|
||||
});
|
||||
description: {
|
||||
id: 'settings.display.banner.developer-mode.description',
|
||||
defaultMessage:
|
||||
"<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode.",
|
||||
},
|
||||
deactivate: {
|
||||
id: 'settings.display.banner.developer-mode.button',
|
||||
defaultMessage: 'Deactivate developer mode',
|
||||
},
|
||||
})
|
||||
|
||||
const colorTheme = defineMessages({
|
||||
title: {
|
||||
id: "settings.display.theme.title",
|
||||
defaultMessage: "Color theme",
|
||||
},
|
||||
description: {
|
||||
id: "settings.display.theme.description",
|
||||
defaultMessage: "Select your preferred color theme for Modrinth on this device.",
|
||||
},
|
||||
});
|
||||
title: {
|
||||
id: 'settings.display.theme.title',
|
||||
defaultMessage: 'Color theme',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.theme.description',
|
||||
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
|
||||
},
|
||||
})
|
||||
|
||||
const projectListLayouts = defineMessages({
|
||||
title: {
|
||||
id: "settings.display.project-list-layouts.title",
|
||||
defaultMessage: "Project list layouts",
|
||||
},
|
||||
description: {
|
||||
id: "settings.display.project-list-layouts.description",
|
||||
defaultMessage:
|
||||
"Select your preferred layout for each page that displays project lists on this device.",
|
||||
},
|
||||
mod: {
|
||||
id: "settings.display.project-list-layouts.mod",
|
||||
defaultMessage: "Mods page",
|
||||
},
|
||||
plugin: {
|
||||
id: "settings.display.project-list-layouts.plugin",
|
||||
defaultMessage: "Plugins page",
|
||||
},
|
||||
datapack: {
|
||||
id: "settings.display.project-list-layouts.datapack",
|
||||
defaultMessage: "Data Packs page",
|
||||
},
|
||||
shader: {
|
||||
id: "settings.display.project-list-layouts.shader",
|
||||
defaultMessage: "Shaders page",
|
||||
},
|
||||
resourcepack: {
|
||||
id: "settings.display.project-list-layouts.resourcepack",
|
||||
defaultMessage: "Resource Packs page",
|
||||
},
|
||||
modpack: {
|
||||
id: "settings.display.project-list-layouts.modpack",
|
||||
defaultMessage: "Modpacks page",
|
||||
},
|
||||
user: {
|
||||
id: "settings.display.project-list-layouts.user",
|
||||
defaultMessage: "User profile pages",
|
||||
},
|
||||
collection: {
|
||||
id: "settings.display.project-list.layouts.collection",
|
||||
defaultMessage: "Collection",
|
||||
},
|
||||
});
|
||||
title: {
|
||||
id: 'settings.display.project-list-layouts.title',
|
||||
defaultMessage: 'Project list layouts',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.project-list-layouts.description',
|
||||
defaultMessage:
|
||||
'Select your preferred layout for each page that displays project lists on this device.',
|
||||
},
|
||||
mod: {
|
||||
id: 'settings.display.project-list-layouts.mod',
|
||||
defaultMessage: 'Mods page',
|
||||
},
|
||||
plugin: {
|
||||
id: 'settings.display.project-list-layouts.plugin',
|
||||
defaultMessage: 'Plugins page',
|
||||
},
|
||||
datapack: {
|
||||
id: 'settings.display.project-list-layouts.datapack',
|
||||
defaultMessage: 'Data Packs page',
|
||||
},
|
||||
shader: {
|
||||
id: 'settings.display.project-list-layouts.shader',
|
||||
defaultMessage: 'Shaders page',
|
||||
},
|
||||
resourcepack: {
|
||||
id: 'settings.display.project-list-layouts.resourcepack',
|
||||
defaultMessage: 'Resource Packs page',
|
||||
},
|
||||
modpack: {
|
||||
id: 'settings.display.project-list-layouts.modpack',
|
||||
defaultMessage: 'Modpacks page',
|
||||
},
|
||||
user: {
|
||||
id: 'settings.display.project-list-layouts.user',
|
||||
defaultMessage: 'User profile pages',
|
||||
},
|
||||
collection: {
|
||||
id: 'settings.display.project-list.layouts.collection',
|
||||
defaultMessage: 'Collection',
|
||||
},
|
||||
})
|
||||
|
||||
const toggleFeatures = defineMessages({
|
||||
title: {
|
||||
id: "settings.display.flags.title",
|
||||
defaultMessage: "Toggle features",
|
||||
},
|
||||
description: {
|
||||
id: "settings.display.flags.description",
|
||||
defaultMessage: "Enable or disable certain features on this device.",
|
||||
},
|
||||
advancedRenderingTitle: {
|
||||
id: "settings.display.sidebar.advanced-rendering.title",
|
||||
defaultMessage: "Advanced rendering",
|
||||
},
|
||||
advancedRenderingDescription: {
|
||||
id: "settings.display.sidebar.advanced-rendering.description",
|
||||
defaultMessage:
|
||||
"Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.",
|
||||
},
|
||||
externalLinksNewTabTitle: {
|
||||
id: "settings.display.sidebar.external-links-new-tab.title",
|
||||
defaultMessage: "Open external links in new tab",
|
||||
},
|
||||
externalLinksNewTabDescription: {
|
||||
id: "settings.display.sidebar.external-links-new-tab.description",
|
||||
defaultMessage:
|
||||
"Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.",
|
||||
},
|
||||
hideModrinthAppPromosTitle: {
|
||||
id: "settings.display.sidebar.hide-app-promos.title",
|
||||
defaultMessage: "Hide Modrinth App promotions",
|
||||
},
|
||||
hideModrinthAppPromosDescription: {
|
||||
id: "settings.display.sidebar.hide-app-promos.description",
|
||||
defaultMessage:
|
||||
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
|
||||
},
|
||||
rightAlignedFiltersSidebarTitle: {
|
||||
id: "settings.display.sidebar.right-aligned-filters-sidebar.title",
|
||||
defaultMessage: "Right-aligned filters sidebar on search pages",
|
||||
},
|
||||
rightAlignedFiltersSidebarDescription: {
|
||||
id: "settings.display.sidebar.right-aligned-filters-sidebar.description",
|
||||
defaultMessage: "Aligns the filters sidebar to the right of the search results.",
|
||||
},
|
||||
leftAlignedContentSidebarTitle: {
|
||||
id: "settings.display.sidebar.left-aligned-content-sidebar.title",
|
||||
defaultMessage: "Left-aligned sidebar on content pages",
|
||||
},
|
||||
leftAlignedContentSidebarDescription: {
|
||||
id: "settings.display.sidebar.right-aligned-content-sidebar.description",
|
||||
defaultMessage: "Aligns the sidebar to the left of the page's content.",
|
||||
},
|
||||
});
|
||||
title: {
|
||||
id: 'settings.display.flags.title',
|
||||
defaultMessage: 'Toggle features',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.flags.description',
|
||||
defaultMessage: 'Enable or disable certain features on this device.',
|
||||
},
|
||||
advancedRenderingTitle: {
|
||||
id: 'settings.display.sidebar.advanced-rendering.title',
|
||||
defaultMessage: 'Advanced rendering',
|
||||
},
|
||||
advancedRenderingDescription: {
|
||||
id: 'settings.display.sidebar.advanced-rendering.description',
|
||||
defaultMessage:
|
||||
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
|
||||
},
|
||||
externalLinksNewTabTitle: {
|
||||
id: 'settings.display.sidebar.external-links-new-tab.title',
|
||||
defaultMessage: 'Open external links in new tab',
|
||||
},
|
||||
externalLinksNewTabDescription: {
|
||||
id: 'settings.display.sidebar.external-links-new-tab.description',
|
||||
defaultMessage:
|
||||
'Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.',
|
||||
},
|
||||
hideModrinthAppPromosTitle: {
|
||||
id: 'settings.display.sidebar.hide-app-promos.title',
|
||||
defaultMessage: 'Hide Modrinth App promotions',
|
||||
},
|
||||
hideModrinthAppPromosDescription: {
|
||||
id: 'settings.display.sidebar.hide-app-promos.description',
|
||||
defaultMessage:
|
||||
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
|
||||
},
|
||||
rightAlignedFiltersSidebarTitle: {
|
||||
id: 'settings.display.sidebar.right-aligned-filters-sidebar.title',
|
||||
defaultMessage: 'Right-aligned filters sidebar on search pages',
|
||||
},
|
||||
rightAlignedFiltersSidebarDescription: {
|
||||
id: 'settings.display.sidebar.right-aligned-filters-sidebar.description',
|
||||
defaultMessage: 'Aligns the filters sidebar to the right of the search results.',
|
||||
},
|
||||
leftAlignedContentSidebarTitle: {
|
||||
id: 'settings.display.sidebar.left-aligned-content-sidebar.title',
|
||||
defaultMessage: 'Left-aligned sidebar on content pages',
|
||||
},
|
||||
leftAlignedContentSidebarDescription: {
|
||||
id: 'settings.display.sidebar.right-aligned-content-sidebar.description',
|
||||
defaultMessage: "Aligns the sidebar to the left of the page's content.",
|
||||
},
|
||||
})
|
||||
|
||||
const cosmetics = useCosmetics();
|
||||
const flags = useFeatureFlags();
|
||||
const tags = useTags();
|
||||
const cosmetics = useCosmetics()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useTags()
|
||||
|
||||
const theme = useTheme();
|
||||
const theme = useTheme()
|
||||
|
||||
// On the server the value of native theme can be 'unknown'. To hydrate
|
||||
// correctly, we need to make sure we aren't using 'unknown' and values between
|
||||
// server and client renders are in sync.
|
||||
|
||||
const serverSystemTheme = useState(() => {
|
||||
const theme_ = theme.native;
|
||||
if (theme_ === "unknown") return "light";
|
||||
return theme_;
|
||||
});
|
||||
const theme_ = theme.native
|
||||
if (theme_ === 'unknown') return 'light'
|
||||
return theme_
|
||||
})
|
||||
|
||||
const systemTheme = useMountedValue((mounted): Theme => {
|
||||
const systemTheme_ = mounted ? theme.native : serverSystemTheme.value;
|
||||
return systemTheme_ === "light" ? theme.preferences.light : theme.preferences.dark;
|
||||
});
|
||||
const systemTheme_ = mounted ? theme.native : serverSystemTheme.value
|
||||
return systemTheme_ === 'light' ? theme.preferences.light : theme.preferences.dark
|
||||
})
|
||||
|
||||
const themeOptions = computed(() => {
|
||||
const options: ("system" | Theme)[] = ["system", "light", "dark", "oled"];
|
||||
if (flags.value.developerMode || theme.preferred === "retro") {
|
||||
options.push("retro");
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const options: ('system' | Theme)[] = ['system', 'light', 'dark', 'oled']
|
||||
if (flags.value.developerMode || theme.preferred === 'retro') {
|
||||
options.push('retro')
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
function updateColorTheme(value: Theme | "system") {
|
||||
if (value !== "system") {
|
||||
if (isDarkTheme(value)) {
|
||||
theme.preferences.dark = value;
|
||||
} else {
|
||||
theme.preferences.light = value;
|
||||
}
|
||||
}
|
||||
function updateColorTheme(value: Theme | 'system') {
|
||||
if (value !== 'system') {
|
||||
if (isDarkTheme(value)) {
|
||||
theme.preferences.dark = value
|
||||
} else {
|
||||
theme.preferences.light = value
|
||||
}
|
||||
}
|
||||
|
||||
theme.preferred = value;
|
||||
theme.preferred = value
|
||||
}
|
||||
|
||||
function disableDeveloperMode() {
|
||||
flags.value.developerMode = !flags.value.developerMode;
|
||||
saveFeatureFlags();
|
||||
addNotification({
|
||||
title: "Developer mode deactivated",
|
||||
text: "Developer mode has been disabled",
|
||||
type: "success",
|
||||
});
|
||||
flags.value.developerMode = !flags.value.developerMode
|
||||
saveFeatureFlags()
|
||||
addNotification({
|
||||
title: 'Developer mode deactivated',
|
||||
text: 'Developer mode has been disabled',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const listTypes = computed(() => {
|
||||
const types = tags.value.projectTypes.map((type) => {
|
||||
return {
|
||||
id: type.id as DisplayLocation,
|
||||
name: formatProjectType(type.id) + "s",
|
||||
display: "the " + formatProjectType(type.id).toLowerCase() + "s search page",
|
||||
};
|
||||
});
|
||||
const types = tags.value.projectTypes.map((type) => {
|
||||
return {
|
||||
id: type.id as DisplayLocation,
|
||||
name: formatProjectType(type.id) + 's',
|
||||
display: 'the ' + formatProjectType(type.id).toLowerCase() + 's search page',
|
||||
}
|
||||
})
|
||||
|
||||
types.push({
|
||||
id: "user" as DisplayLocation,
|
||||
name: "User profiles",
|
||||
display: "user pages",
|
||||
});
|
||||
types.push({
|
||||
id: 'user' as DisplayLocation,
|
||||
name: 'User profiles',
|
||||
display: 'user pages',
|
||||
})
|
||||
|
||||
return types;
|
||||
});
|
||||
return types
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.project-lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
|
||||
> :first-child .label__title {
|
||||
margin-top: 0;
|
||||
}
|
||||
> :first-child .label__title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.preview {
|
||||
--_layout-width: 7rem;
|
||||
--_layout-height: 4.5rem;
|
||||
--_layout-gap: 0.25rem;
|
||||
.preview {
|
||||
--_layout-width: 7rem;
|
||||
--_layout-height: 4.5rem;
|
||||
--_layout-gap: 0.25rem;
|
||||
|
||||
.example-card {
|
||||
border-radius: 0.5rem;
|
||||
width: var(--_layout-width);
|
||||
height: calc((var(--_layout-height) - 3 * var(--_layout-gap)) / 4);
|
||||
padding: 0;
|
||||
}
|
||||
.example-card {
|
||||
border-radius: 0.5rem;
|
||||
width: var(--_layout-width);
|
||||
height: calc((var(--_layout-height) - 3 * var(--_layout-gap)) / 4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.layout-list-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
}
|
||||
.layout-list-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
}
|
||||
|
||||
.layout-grid-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
.layout-grid-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
|
||||
.example-card {
|
||||
width: calc((var(--_layout-width) - 2 * var(--_layout-gap)) / 3);
|
||||
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
|
||||
}
|
||||
}
|
||||
.example-card {
|
||||
width: calc((var(--_layout-width) - 2 * var(--_layout-gap)) / 3);
|
||||
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-gallery-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
.layout-gallery-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
|
||||
.example-card {
|
||||
width: calc((var(--_layout-width) - var(--_layout-gap)) / 2);
|
||||
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.example-card {
|
||||
width: calc((var(--_layout-width) - var(--_layout-gap)) / 2);
|
||||
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-list-layouts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.preview-radio .example-card {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.preview-radio .example-card {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.preview-radio.selected .example-card {
|
||||
border-color: var(--color-brand);
|
||||
background-color: var(--color-brand-highlight);
|
||||
}
|
||||
.preview-radio.selected .example-card {
|
||||
border-color: var(--color-brand);
|
||||
background-color: var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.developer-message {
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
margin-bottom: 2px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
margin-bottom: 2px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: var(--gap-sm);
|
||||
}
|
||||
.btn {
|
||||
margin-top: var(--gap-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,526 +1,527 @@
|
||||
<script setup lang="ts">
|
||||
import Fuse from "fuse.js/dist/fuse.basic";
|
||||
import { commonSettingsMessages } from "@modrinth/ui";
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { isModifierKeyDown } from "~/helpers/events.ts";
|
||||
import { IssuesIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import { commonSettingsMessages } from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js/dist/fuse.basic'
|
||||
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
import { isModifierKeyDown } from '~/helpers/events.ts'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
const messages = defineMessages({
|
||||
languagesDescription: {
|
||||
id: "settings.language.description",
|
||||
defaultMessage:
|
||||
"Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.",
|
||||
},
|
||||
automaticLocale: {
|
||||
id: "settings.language.languages.automatic",
|
||||
defaultMessage: "Sync with the system language",
|
||||
},
|
||||
noResults: {
|
||||
id: "settings.language.languages.search.no-results",
|
||||
defaultMessage: "No languages match your search.",
|
||||
},
|
||||
searchFieldDescription: {
|
||||
id: "settings.language.languages.search-field.description",
|
||||
defaultMessage: "Submit to focus the first search result",
|
||||
},
|
||||
searchFieldPlaceholder: {
|
||||
id: "settings.language.languages.search-field.placeholder",
|
||||
defaultMessage: "Search for a language...",
|
||||
},
|
||||
searchResultsAnnouncement: {
|
||||
id: "settings.language.languages.search-results-announcement",
|
||||
defaultMessage:
|
||||
"{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.",
|
||||
},
|
||||
loadFailed: {
|
||||
id: "settings.language.languages.load-failed",
|
||||
defaultMessage: "Cannot load this language. Try again in a bit.",
|
||||
},
|
||||
languageLabelApplying: {
|
||||
id: "settings.language.languages.language-label-applying",
|
||||
defaultMessage: "{label}. Applying...",
|
||||
},
|
||||
languageLabelError: {
|
||||
id: "settings.language.languages.language-label-error",
|
||||
defaultMessage: "{label}. Error",
|
||||
},
|
||||
});
|
||||
languagesDescription: {
|
||||
id: 'settings.language.description',
|
||||
defaultMessage:
|
||||
'Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
|
||||
},
|
||||
automaticLocale: {
|
||||
id: 'settings.language.languages.automatic',
|
||||
defaultMessage: 'Sync with the system language',
|
||||
},
|
||||
noResults: {
|
||||
id: 'settings.language.languages.search.no-results',
|
||||
defaultMessage: 'No languages match your search.',
|
||||
},
|
||||
searchFieldDescription: {
|
||||
id: 'settings.language.languages.search-field.description',
|
||||
defaultMessage: 'Submit to focus the first search result',
|
||||
},
|
||||
searchFieldPlaceholder: {
|
||||
id: 'settings.language.languages.search-field.placeholder',
|
||||
defaultMessage: 'Search for a language...',
|
||||
},
|
||||
searchResultsAnnouncement: {
|
||||
id: 'settings.language.languages.search-results-announcement',
|
||||
defaultMessage:
|
||||
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
|
||||
},
|
||||
loadFailed: {
|
||||
id: 'settings.language.languages.load-failed',
|
||||
defaultMessage: 'Cannot load this language. Try again in a bit.',
|
||||
},
|
||||
languageLabelApplying: {
|
||||
id: 'settings.language.languages.language-label-applying',
|
||||
defaultMessage: '{label}. Applying...',
|
||||
},
|
||||
languageLabelError: {
|
||||
id: 'settings.language.languages.language-label-error',
|
||||
defaultMessage: '{label}. Error',
|
||||
},
|
||||
})
|
||||
|
||||
const categoryNames = defineMessages({
|
||||
auto: {
|
||||
id: "settings.language.categories.auto",
|
||||
defaultMessage: "Automatic",
|
||||
},
|
||||
default: {
|
||||
id: "settings.language.categories.default",
|
||||
defaultMessage: "Standard languages",
|
||||
},
|
||||
fun: {
|
||||
id: "settings.language.categories.fun",
|
||||
defaultMessage: "Fun languages",
|
||||
},
|
||||
experimental: {
|
||||
id: "settings.language.categories.experimental",
|
||||
defaultMessage: "Experimental languages",
|
||||
},
|
||||
searchResult: {
|
||||
id: "settings.language.categories.search-result",
|
||||
defaultMessage: "Search results",
|
||||
},
|
||||
});
|
||||
auto: {
|
||||
id: 'settings.language.categories.auto',
|
||||
defaultMessage: 'Automatic',
|
||||
},
|
||||
default: {
|
||||
id: 'settings.language.categories.default',
|
||||
defaultMessage: 'Standard languages',
|
||||
},
|
||||
fun: {
|
||||
id: 'settings.language.categories.fun',
|
||||
defaultMessage: 'Fun languages',
|
||||
},
|
||||
experimental: {
|
||||
id: 'settings.language.categories.experimental',
|
||||
defaultMessage: 'Experimental languages',
|
||||
},
|
||||
searchResult: {
|
||||
id: 'settings.language.categories.search-result',
|
||||
defaultMessage: 'Search results',
|
||||
},
|
||||
})
|
||||
|
||||
type Category = keyof typeof categoryNames;
|
||||
type Category = keyof typeof categoryNames
|
||||
|
||||
const categoryOrder: Category[] = ["auto", "default", "fun", "experimental"];
|
||||
const categoryOrder: Category[] = ['auto', 'default', 'fun', 'experimental']
|
||||
|
||||
function normalizeCategoryName(name?: string): keyof typeof categoryNames {
|
||||
switch (name) {
|
||||
case "auto":
|
||||
case "fun":
|
||||
case "experimental":
|
||||
return name;
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
switch (name) {
|
||||
case 'auto':
|
||||
case 'fun':
|
||||
case 'experimental':
|
||||
return name
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
type LocaleBase = {
|
||||
category: Category;
|
||||
tag: string;
|
||||
searchTerms?: string[];
|
||||
};
|
||||
|
||||
type AutomaticLocale = LocaleBase & {
|
||||
auto: true;
|
||||
};
|
||||
|
||||
type CommonLocale = LocaleBase & {
|
||||
auto?: never;
|
||||
displayName: string;
|
||||
defaultName: string;
|
||||
translatedName: string;
|
||||
};
|
||||
|
||||
type Locale = AutomaticLocale | CommonLocale;
|
||||
|
||||
const $defaultNames = useDisplayNames(() => vintl.defaultLocale);
|
||||
|
||||
const $translatedNames = useDisplayNames(() => vintl.locale);
|
||||
|
||||
const $locales = computed(() => {
|
||||
const locales: Locale[] = [];
|
||||
|
||||
locales.push({
|
||||
auto: true,
|
||||
tag: "auto",
|
||||
category: "auto",
|
||||
searchTerms: [
|
||||
"automatic",
|
||||
"Sync with the system language",
|
||||
formatMessage(messages.automaticLocale),
|
||||
],
|
||||
});
|
||||
|
||||
for (const locale of vintl.availableLocales) {
|
||||
let displayName = locale.meta?.displayName;
|
||||
|
||||
if (displayName == null) {
|
||||
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag;
|
||||
}
|
||||
|
||||
let defaultName = vintl.defaultResources["languages.json"]?.[locale.tag];
|
||||
|
||||
if (defaultName == null) {
|
||||
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag;
|
||||
}
|
||||
|
||||
let translatedName = vintl.resources["languages.json"]?.[locale.tag];
|
||||
|
||||
if (translatedName == null) {
|
||||
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag;
|
||||
}
|
||||
|
||||
let searchTerms = locale.meta?.searchTerms;
|
||||
if (searchTerms === "-") searchTerms = undefined;
|
||||
|
||||
locales.push({
|
||||
tag: locale.tag,
|
||||
category: normalizeCategoryName(locale.meta?.category),
|
||||
displayName,
|
||||
defaultName,
|
||||
translatedName,
|
||||
searchTerms: searchTerms?.split("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
return locales;
|
||||
});
|
||||
|
||||
const $query = ref("");
|
||||
|
||||
const isQueryEmpty = () => $query.value.trim().length === 0;
|
||||
|
||||
const fuse = new Fuse<Locale>([], {
|
||||
keys: ["tag", "displayName", "translatedName", "englishName", "searchTerms"],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
});
|
||||
|
||||
watchSyncEffect(() => fuse.setCollection($locales.value));
|
||||
|
||||
const $categories = computed(() => {
|
||||
const categories = new Map<Category, Locale[]>();
|
||||
|
||||
for (const category of categoryOrder) categories.set(category, []);
|
||||
|
||||
for (const locale of $locales.value) {
|
||||
let categoryLocales = categories.get(locale.category);
|
||||
|
||||
if (categoryLocales == null) {
|
||||
categoryLocales = [];
|
||||
categories.set(locale.category, categoryLocales);
|
||||
}
|
||||
|
||||
categoryLocales.push(locale);
|
||||
}
|
||||
|
||||
for (const categoryKey of [...categories.keys()]) {
|
||||
if (categories.get(categoryKey)?.length === 0) {
|
||||
categories.delete(categoryKey);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
});
|
||||
|
||||
const $searchResults = computed(() => {
|
||||
return new Map<Category, Locale[]>([
|
||||
["searchResult", isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
|
||||
]);
|
||||
});
|
||||
|
||||
const $displayCategories = computed(() =>
|
||||
isQueryEmpty() ? $categories.value : $searchResults.value,
|
||||
);
|
||||
|
||||
const $changingTo = ref<string | undefined>();
|
||||
|
||||
const isChanging = () => $changingTo.value != null;
|
||||
|
||||
const $failedLocale = ref<string>();
|
||||
|
||||
const $activeLocale = computed(() => {
|
||||
if ($changingTo.value != null) return $changingTo.value;
|
||||
return vintl.automatic ? "auto" : vintl.locale;
|
||||
});
|
||||
|
||||
async function changeLocale(value: string) {
|
||||
if ($activeLocale.value === value) return;
|
||||
|
||||
$changingTo.value = value;
|
||||
|
||||
try {
|
||||
await vintl.changeLocale(value);
|
||||
$failedLocale.value = undefined;
|
||||
} catch {
|
||||
$failedLocale.value = value;
|
||||
} finally {
|
||||
$changingTo.value = undefined;
|
||||
}
|
||||
category: Category
|
||||
tag: string
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
const $languagesList = ref<HTMLDivElement | undefined>();
|
||||
type AutomaticLocale = LocaleBase & {
|
||||
auto: true
|
||||
}
|
||||
|
||||
type CommonLocale = LocaleBase & {
|
||||
auto?: never
|
||||
displayName: string
|
||||
defaultName: string
|
||||
translatedName: string
|
||||
}
|
||||
|
||||
type Locale = AutomaticLocale | CommonLocale
|
||||
|
||||
const $defaultNames = useDisplayNames(() => vintl.defaultLocale)
|
||||
|
||||
const $translatedNames = useDisplayNames(() => vintl.locale)
|
||||
|
||||
const $locales = computed(() => {
|
||||
const locales: Locale[] = []
|
||||
|
||||
locales.push({
|
||||
auto: true,
|
||||
tag: 'auto',
|
||||
category: 'auto',
|
||||
searchTerms: [
|
||||
'automatic',
|
||||
'Sync with the system language',
|
||||
formatMessage(messages.automaticLocale),
|
||||
],
|
||||
})
|
||||
|
||||
for (const locale of vintl.availableLocales) {
|
||||
let displayName = locale.meta?.displayName
|
||||
|
||||
if (displayName == null) {
|
||||
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag
|
||||
}
|
||||
|
||||
let defaultName = vintl.defaultResources['languages.json']?.[locale.tag]
|
||||
|
||||
if (defaultName == null) {
|
||||
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag
|
||||
}
|
||||
|
||||
let translatedName = vintl.resources['languages.json']?.[locale.tag]
|
||||
|
||||
if (translatedName == null) {
|
||||
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag
|
||||
}
|
||||
|
||||
let searchTerms = locale.meta?.searchTerms
|
||||
if (searchTerms === '-') searchTerms = undefined
|
||||
|
||||
locales.push({
|
||||
tag: locale.tag,
|
||||
category: normalizeCategoryName(locale.meta?.category),
|
||||
displayName,
|
||||
defaultName,
|
||||
translatedName,
|
||||
searchTerms: searchTerms?.split('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
return locales
|
||||
})
|
||||
|
||||
const $query = ref('')
|
||||
|
||||
const isQueryEmpty = () => $query.value.trim().length === 0
|
||||
|
||||
const fuse = new Fuse<Locale>([], {
|
||||
keys: ['tag', 'displayName', 'translatedName', 'englishName', 'searchTerms'],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
})
|
||||
|
||||
watchSyncEffect(() => fuse.setCollection($locales.value))
|
||||
|
||||
const $categories = computed(() => {
|
||||
const categories = new Map<Category, Locale[]>()
|
||||
|
||||
for (const category of categoryOrder) categories.set(category, [])
|
||||
|
||||
for (const locale of $locales.value) {
|
||||
let categoryLocales = categories.get(locale.category)
|
||||
|
||||
if (categoryLocales == null) {
|
||||
categoryLocales = []
|
||||
categories.set(locale.category, categoryLocales)
|
||||
}
|
||||
|
||||
categoryLocales.push(locale)
|
||||
}
|
||||
|
||||
for (const categoryKey of [...categories.keys()]) {
|
||||
if (categories.get(categoryKey)?.length === 0) {
|
||||
categories.delete(categoryKey)
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
})
|
||||
|
||||
const $searchResults = computed(() => {
|
||||
return new Map<Category, Locale[]>([
|
||||
['searchResult', isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
|
||||
])
|
||||
})
|
||||
|
||||
const $displayCategories = computed(() =>
|
||||
isQueryEmpty() ? $categories.value : $searchResults.value,
|
||||
)
|
||||
|
||||
const $changingTo = ref<string | undefined>()
|
||||
|
||||
const isChanging = () => $changingTo.value != null
|
||||
|
||||
const $failedLocale = ref<string>()
|
||||
|
||||
const $activeLocale = computed(() => {
|
||||
if ($changingTo.value != null) return $changingTo.value
|
||||
return vintl.automatic ? 'auto' : vintl.locale
|
||||
})
|
||||
|
||||
async function changeLocale(value: string) {
|
||||
if ($activeLocale.value === value) return
|
||||
|
||||
$changingTo.value = value
|
||||
|
||||
try {
|
||||
await vintl.changeLocale(value)
|
||||
$failedLocale.value = undefined
|
||||
} catch {
|
||||
$failedLocale.value = value
|
||||
} finally {
|
||||
$changingTo.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const $languagesList = ref<HTMLDivElement | undefined>()
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Enter" || isModifierKeyDown(e)) return;
|
||||
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
|
||||
|
||||
const focusableTarget = $languagesList.value?.querySelector(
|
||||
'input, [tabindex]:not([tabindex="-1"])',
|
||||
) as HTMLElement | undefined;
|
||||
const focusableTarget = $languagesList.value?.querySelector(
|
||||
'input, [tabindex]:not([tabindex="-1"])',
|
||||
) as HTMLElement | undefined
|
||||
|
||||
focusableTarget?.focus();
|
||||
focusableTarget?.focus()
|
||||
}
|
||||
|
||||
function onItemKeydown(e: KeyboardEvent, locale: Locale) {
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (isModifierKeyDown(e) || isChanging()) return;
|
||||
if (isModifierKeyDown(e) || isChanging()) return
|
||||
|
||||
changeLocale(locale.tag);
|
||||
changeLocale(locale.tag)
|
||||
}
|
||||
|
||||
function onItemClick(e: MouseEvent, locale: Locale) {
|
||||
if (isModifierKeyDown(e) || isChanging()) return;
|
||||
if (isModifierKeyDown(e) || isChanging()) return
|
||||
|
||||
changeLocale(locale.tag);
|
||||
changeLocale(locale.tag)
|
||||
}
|
||||
|
||||
function getItemLabel(locale: Locale) {
|
||||
const label = locale.auto
|
||||
? formatMessage(messages.automaticLocale)
|
||||
: `${locale.translatedName}. ${locale.displayName}`;
|
||||
const label = locale.auto
|
||||
? formatMessage(messages.automaticLocale)
|
||||
: `${locale.translatedName}. ${locale.displayName}`
|
||||
|
||||
if ($changingTo.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelApplying, { label });
|
||||
}
|
||||
if ($changingTo.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelApplying, { label })
|
||||
}
|
||||
|
||||
if ($failedLocale.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelError, { label });
|
||||
}
|
||||
if ($failedLocale.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelError, { label })
|
||||
}
|
||||
|
||||
return label;
|
||||
return label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.language) }}</h2>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.language) }}</h2>
|
||||
|
||||
<div class="card-description">
|
||||
<IntlFormatted :message-id="messages.languagesDescription">
|
||||
<template #crowdin-link="{ children }">
|
||||
<a href="https://crowdin.com/project/modrinth">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div class="card-description">
|
||||
<IntlFormatted :message-id="messages.languagesDescription">
|
||||
<template #crowdin-link="{ children }">
|
||||
<a href="https://crowdin.com/project/modrinth">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input
|
||||
id="language-search"
|
||||
v-model="$query"
|
||||
name="language"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
|
||||
class="language-search"
|
||||
aria-describedby="language-search-description"
|
||||
:disabled="isChanging()"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<div class="search-container">
|
||||
<input
|
||||
id="language-search"
|
||||
v-model="$query"
|
||||
name="language"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
|
||||
class="language-search"
|
||||
aria-describedby="language-search-description"
|
||||
:disabled="isChanging()"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
|
||||
<div id="language-search-description" class="visually-hidden">
|
||||
{{ formatMessage(messages.searchFieldDescription) }}
|
||||
</div>
|
||||
<div id="language-search-description" class="visually-hidden">
|
||||
{{ formatMessage(messages.searchFieldDescription) }}
|
||||
</div>
|
||||
|
||||
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
|
||||
{{
|
||||
isQueryEmpty()
|
||||
? ""
|
||||
: formatMessage(messages.searchResultsAnnouncement, {
|
||||
matches: $searchResults.get("searchResult")?.length ?? 0,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
|
||||
{{
|
||||
isQueryEmpty()
|
||||
? ''
|
||||
: formatMessage(messages.searchResultsAnnouncement, {
|
||||
matches: $searchResults.get('searchResult')?.length ?? 0,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="$languagesList" class="languages-list">
|
||||
<template v-for="[category, locales] in $displayCategories" :key="category">
|
||||
<strong class="category-name">
|
||||
{{ formatMessage(categoryNames[category]) }}
|
||||
</strong>
|
||||
<div ref="$languagesList" class="languages-list">
|
||||
<template v-for="[category, locales] in $displayCategories" :key="category">
|
||||
<strong class="category-name">
|
||||
{{ formatMessage(categoryNames[category]) }}
|
||||
</strong>
|
||||
|
||||
<div
|
||||
v-if="category === 'searchResult' && locales.length === 0"
|
||||
class="no-results"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ formatMessage(messages.noResults) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="category === 'searchResult' && locales.length === 0"
|
||||
class="no-results"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ formatMessage(messages.noResults) }}
|
||||
</div>
|
||||
|
||||
<template v-for="locale in locales" :key="locale.tag">
|
||||
<div
|
||||
role="button"
|
||||
:aria-pressed="$activeLocale === locale.tag"
|
||||
:class="{
|
||||
'language-item': true,
|
||||
pending: $changingTo == locale.tag,
|
||||
errored: $failedLocale == locale.tag,
|
||||
}"
|
||||
:aria-describedby="
|
||||
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
|
||||
"
|
||||
:aria-disabled="isChanging() && $changingTo !== locale.tag"
|
||||
:tabindex="0"
|
||||
:aria-label="getItemLabel(locale)"
|
||||
@click="(e) => onItemClick(e, locale)"
|
||||
@keydown="(e) => onItemKeydown(e, locale)"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
<template v-for="locale in locales" :key="locale.tag">
|
||||
<div
|
||||
role="button"
|
||||
:aria-pressed="$activeLocale === locale.tag"
|
||||
:class="{
|
||||
'language-item': true,
|
||||
pending: $changingTo == locale.tag,
|
||||
errored: $failedLocale == locale.tag,
|
||||
}"
|
||||
:aria-describedby="
|
||||
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
|
||||
"
|
||||
:aria-disabled="isChanging() && $changingTo !== locale.tag"
|
||||
:tabindex="0"
|
||||
:aria-label="getItemLabel(locale)"
|
||||
@click="(e) => onItemClick(e, locale)"
|
||||
@keydown="(e) => onItemKeydown(e, locale)"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
|
||||
<div class="language-names">
|
||||
<div class="language-name">
|
||||
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
|
||||
</div>
|
||||
<div class="language-names">
|
||||
<div class="language-name">
|
||||
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
|
||||
</div>
|
||||
|
||||
<div v-if="!locale.auto" class="language-translated-name">
|
||||
{{ locale.translatedName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!locale.auto" class="language-translated-name">
|
||||
{{ locale.translatedName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$failedLocale === locale.tag"
|
||||
:id="`language__${locale.tag}__fail`"
|
||||
class="language-load-error"
|
||||
>
|
||||
<IssuesIcon />
|
||||
{{ formatMessage(messages.loadFailed) }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div
|
||||
v-if="$failedLocale === locale.tag"
|
||||
:id="`language__${locale.tag}__fail`"
|
||||
class="language-load-error"
|
||||
>
|
||||
<IssuesIcon />
|
||||
{{ formatMessage(messages.loadFailed) }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.languages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
border: 0.15rem solid transparent;
|
||||
border-radius: var(--spacing-card-md);
|
||||
background: var(--color-button-bg);
|
||||
padding: var(--spacing-card-md);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
border: 0.15rem solid transparent;
|
||||
border-radius: var(--spacing-card-md);
|
||||
background: var(--color-button-bg);
|
||||
padding: var(--spacing-card-md);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:not([aria-disabled="true"]):hover {
|
||||
border-color: var(--color-button-bg-hover);
|
||||
}
|
||||
&:not([aria-disabled='true']):hover {
|
||||
border-color: var(--color-button-bg-hover);
|
||||
}
|
||||
|
||||
&:focus-visible,
|
||||
&:has(:focus-visible) {
|
||||
outline: 2px solid var(--color-brand);
|
||||
}
|
||||
&:focus-visible,
|
||||
&:has(:focus-visible) {
|
||||
outline: 2px solid var(--color-brand);
|
||||
}
|
||||
|
||||
&.errored {
|
||||
border-color: var(--color-red);
|
||||
&.errored {
|
||||
border-color: var(--color-red);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
}
|
||||
|
||||
&.pending::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
&.pending::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-image: linear-gradient(
|
||||
102deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0) 20%,
|
||||
rgba(0, 0, 0, 0.1) 45%,
|
||||
rgba(0, 0, 0, 0.1) 50%,
|
||||
rgba(0, 0, 0, 0) 80%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
background-image: linear-gradient(
|
||||
102deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0) 20%,
|
||||
rgba(0, 0, 0, 0.1) 45%,
|
||||
rgba(0, 0, 0, 0.1) 50%,
|
||||
rgba(0, 0, 0, 0) 80%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
|
||||
background-repeat: no-repeat;
|
||||
animation: shimmerSliding 2.5s ease-out infinite;
|
||||
background-repeat: no-repeat;
|
||||
animation: shimmerSliding 2.5s ease-out infinite;
|
||||
|
||||
.dark-mode &,
|
||||
.oled-mode & {
|
||||
background-image: linear-gradient(
|
||||
102deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) 20%,
|
||||
rgba(255, 255, 255, 0.1) 45%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 80%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
.dark-mode &,
|
||||
.oled-mode & {
|
||||
background-image: linear-gradient(
|
||||
102deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) 20%,
|
||||
rgba(255, 255, 255, 0.1) 45%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 80%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes shimmerSliding {
|
||||
from {
|
||||
left: -100%;
|
||||
}
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes shimmerSliding {
|
||||
from {
|
||||
left: -100%;
|
||||
}
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-disabled="true"]:not(.pending) {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
&[aria-disabled='true']:not(.pending) {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.language-load-error {
|
||||
color: var(--color-red);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-left: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--color-red);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-left: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.language-names {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.language-name {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.language-search {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
|
||||
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
a {
|
||||
color: var(--color-link);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-link-active);
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
color: var(--color-link-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
margin-top: var(--spacing-card-md);
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,430 +1,431 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
:title="formatMessage(deleteModalMessages.title)"
|
||||
:description="formatMessage(deleteModalMessages.description)"
|
||||
:proceed-label="formatMessage(deleteModalMessages.action)"
|
||||
@proceed="removePat(deletePatIndex)"
|
||||
/>
|
||||
<Modal
|
||||
ref="patModal"
|
||||
:header="
|
||||
editPatIndex !== null
|
||||
? formatMessage(createModalMessages.editTitle)
|
||||
: formatMessage(createModalMessages.createTitle)
|
||||
"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<label for="pat-name">
|
||||
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="pat-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="formatMessage(createModalMessages.namePlaceholder)"
|
||||
/>
|
||||
<label for="pat-scopes">
|
||||
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
|
||||
</label>
|
||||
<div id="pat-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="scope in scopeList"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
|
||||
/>
|
||||
</div>
|
||||
<label for="pat-name">
|
||||
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
|
||||
</label>
|
||||
<input id="pat-name" v-model="expires" type="date" />
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.patModal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editPatIndex !== null"
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="editPat"
|
||||
>
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="createPat"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(createModalMessages.action) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
:title="formatMessage(deleteModalMessages.title)"
|
||||
:description="formatMessage(deleteModalMessages.description)"
|
||||
:proceed-label="formatMessage(deleteModalMessages.action)"
|
||||
@proceed="removePat(deletePatIndex)"
|
||||
/>
|
||||
<Modal
|
||||
ref="patModal"
|
||||
:header="
|
||||
editPatIndex !== null
|
||||
? formatMessage(createModalMessages.editTitle)
|
||||
: formatMessage(createModalMessages.createTitle)
|
||||
"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<label for="pat-name">
|
||||
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="pat-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="formatMessage(createModalMessages.namePlaceholder)"
|
||||
/>
|
||||
<label for="pat-scopes">
|
||||
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
|
||||
</label>
|
||||
<div id="pat-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="scope in scopeList"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
|
||||
/>
|
||||
</div>
|
||||
<label for="pat-name">
|
||||
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
|
||||
</label>
|
||||
<input id="pat-name" v-model="expires" type="date" />
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.patModal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editPatIndex !== null"
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="editPat"
|
||||
>
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="createPat"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(createModalMessages.action) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.pats) }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
() => {
|
||||
name = null;
|
||||
scopesVal = 0;
|
||||
expires = null;
|
||||
editPatIndex = null;
|
||||
$refs.patModal.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> {{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #doc-link="{ children }">
|
||||
<a class="text-link" href="https://docs.modrinth.com">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<div v-for="(pat, index) in pats" :key="pat.id" class="universal-card recessed token">
|
||||
<div>
|
||||
<div>
|
||||
<strong>{{ pat.name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="pat.access_token">
|
||||
<CopyCode :text="pat.access_token" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-tooltip="
|
||||
pat.last_used
|
||||
? formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.last_used),
|
||||
time: new Date(pat.last_used),
|
||||
})
|
||||
: null
|
||||
"
|
||||
>
|
||||
<template v-if="pat.last_used">
|
||||
{{
|
||||
formatMessage(tokenMessages.lastUsed, {
|
||||
ago: formatRelativeTime(pat.last_used),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>{{ formatMessage(tokenMessages.neverUsed) }}</template>
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.expires),
|
||||
time: new Date(pat.expires),
|
||||
})
|
||||
"
|
||||
>
|
||||
<template v-if="new Date(pat.expires) > new Date()">
|
||||
{{
|
||||
formatMessage(tokenMessages.expiresIn, {
|
||||
inTime: formatRelativeTime(pat.expires),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
formatMessage(tokenMessages.expiredAgo, {
|
||||
ago: formatRelativeTime(pat.expires),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.created),
|
||||
time: new Date(pat.created),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(commonMessages.createdAgoLabel, {
|
||||
ago: formatRelativeTime(pat.created),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
() => {
|
||||
editPatIndex = index;
|
||||
name = pat.name;
|
||||
scopesVal = pat.scopes;
|
||||
expires = $dayjs(pat.expires).format('YYYY-MM-DD');
|
||||
$refs.patModal.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(tokenMessages.edit) }}
|
||||
</button>
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
() => {
|
||||
deletePatIndex = pat.id;
|
||||
$refs.modal_confirm.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon /> {{ formatMessage(tokenMessages.revoke) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.pats) }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
() => {
|
||||
name = null
|
||||
scopesVal = 0
|
||||
expires = null
|
||||
editPatIndex = null
|
||||
$refs.patModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> {{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #doc-link="{ children }">
|
||||
<a class="text-link" href="https://docs.modrinth.com">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<div v-for="(pat, index) in pats" :key="pat.id" class="universal-card recessed token">
|
||||
<div>
|
||||
<div>
|
||||
<strong>{{ pat.name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="pat.access_token">
|
||||
<CopyCode :text="pat.access_token" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-tooltip="
|
||||
pat.last_used
|
||||
? formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.last_used),
|
||||
time: new Date(pat.last_used),
|
||||
})
|
||||
: null
|
||||
"
|
||||
>
|
||||
<template v-if="pat.last_used">
|
||||
{{
|
||||
formatMessage(tokenMessages.lastUsed, {
|
||||
ago: formatRelativeTime(pat.last_used),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>{{ formatMessage(tokenMessages.neverUsed) }}</template>
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.expires),
|
||||
time: new Date(pat.expires),
|
||||
})
|
||||
"
|
||||
>
|
||||
<template v-if="new Date(pat.expires) > new Date()">
|
||||
{{
|
||||
formatMessage(tokenMessages.expiresIn, {
|
||||
inTime: formatRelativeTime(pat.expires),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
formatMessage(tokenMessages.expiredAgo, {
|
||||
ago: formatRelativeTime(pat.expires),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.created),
|
||||
time: new Date(pat.created),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(commonMessages.createdAgoLabel, {
|
||||
ago: formatRelativeTime(pat.created),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
() => {
|
||||
editPatIndex = index
|
||||
name = pat.name
|
||||
scopesVal = pat.scopes
|
||||
expires = $dayjs(pat.expires).format('YYYY-MM-DD')
|
||||
$refs.patModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(tokenMessages.edit) }}
|
||||
</button>
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
() => {
|
||||
deletePatIndex = pat.id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon /> {{ formatMessage(tokenMessages.revoke) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { EditIcon, PlusIcon, SaveIcon, TrashIcon, XIcon } from "@modrinth/assets";
|
||||
import { EditIcon, PlusIcon, SaveIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Checkbox,
|
||||
ConfirmModal,
|
||||
CopyCode,
|
||||
commonMessages,
|
||||
commonSettingsMessages,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
} from "@modrinth/ui";
|
||||
import Modal from "~/components/ui/Modal.vue";
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
commonSettingsMessages,
|
||||
ConfirmModal,
|
||||
CopyCode,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import {
|
||||
getScopeValue,
|
||||
hasScope,
|
||||
scopeList,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
} from "~/composables/auth/scopes.ts";
|
||||
getScopeValue,
|
||||
hasScope,
|
||||
scopeList,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
} from '~/composables/auth/scopes.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const { formatMessage } = useVIntl();
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const createModalMessages = defineMessages({
|
||||
createTitle: {
|
||||
id: "settings.pats.modal.create.title",
|
||||
defaultMessage: "Create personal access token",
|
||||
},
|
||||
editTitle: {
|
||||
id: "settings.pats.modal.edit.title",
|
||||
defaultMessage: "Edit personal access token",
|
||||
},
|
||||
nameLabel: {
|
||||
id: "settings.pats.modal.create.name.label",
|
||||
defaultMessage: "Name",
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: "settings.pats.modal.create.name.placeholder",
|
||||
defaultMessage: "Enter the PAT's name...",
|
||||
},
|
||||
expiresLabel: {
|
||||
id: "settings.pats.modal.create.expires.label",
|
||||
defaultMessage: "Expires",
|
||||
},
|
||||
action: {
|
||||
id: "settings.pats.modal.create.action",
|
||||
defaultMessage: "Create PAT",
|
||||
},
|
||||
});
|
||||
createTitle: {
|
||||
id: 'settings.pats.modal.create.title',
|
||||
defaultMessage: 'Create personal access token',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'settings.pats.modal.edit.title',
|
||||
defaultMessage: 'Edit personal access token',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'settings.pats.modal.create.name.label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'settings.pats.modal.create.name.placeholder',
|
||||
defaultMessage: "Enter the PAT's name...",
|
||||
},
|
||||
expiresLabel: {
|
||||
id: 'settings.pats.modal.create.expires.label',
|
||||
defaultMessage: 'Expires',
|
||||
},
|
||||
action: {
|
||||
id: 'settings.pats.modal.create.action',
|
||||
defaultMessage: 'Create PAT',
|
||||
},
|
||||
})
|
||||
|
||||
const deleteModalMessages = defineMessages({
|
||||
title: {
|
||||
id: "settings.pats.modal.delete.title",
|
||||
defaultMessage: "Are you sure you want to delete this token?",
|
||||
},
|
||||
description: {
|
||||
id: "settings.pats.modal.delete.description",
|
||||
defaultMessage: "This will remove this token forever (like really forever).",
|
||||
},
|
||||
action: {
|
||||
id: "settings.pats.modal.delete.action",
|
||||
defaultMessage: "Delete this token",
|
||||
},
|
||||
});
|
||||
title: {
|
||||
id: 'settings.pats.modal.delete.title',
|
||||
defaultMessage: 'Are you sure you want to delete this token?',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.pats.modal.delete.description',
|
||||
defaultMessage: 'This will remove this token forever (like really forever).',
|
||||
},
|
||||
action: {
|
||||
id: 'settings.pats.modal.delete.action',
|
||||
defaultMessage: 'Delete this token',
|
||||
},
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
description: {
|
||||
id: "settings.pats.description",
|
||||
defaultMessage:
|
||||
"PATs can be used to access Modrinth's API. For more information, see <doc-link>Modrinth's API documentation</doc-link>. They can be created and revoked at any time.",
|
||||
},
|
||||
create: {
|
||||
id: "settings.pats.action.create",
|
||||
defaultMessage: "Create a PAT",
|
||||
},
|
||||
});
|
||||
description: {
|
||||
id: 'settings.pats.description',
|
||||
defaultMessage:
|
||||
"PATs can be used to access Modrinth's API. For more information, see <doc-link>Modrinth's API documentation</doc-link>. They can be created and revoked at any time.",
|
||||
},
|
||||
create: {
|
||||
id: 'settings.pats.action.create',
|
||||
defaultMessage: 'Create a PAT',
|
||||
},
|
||||
})
|
||||
|
||||
const tokenMessages = defineMessages({
|
||||
edit: {
|
||||
id: "settings.pats.token.action.edit",
|
||||
defaultMessage: "Edit token",
|
||||
},
|
||||
revoke: {
|
||||
id: "settings.pats.token.action.revoke",
|
||||
defaultMessage: "Revoke token",
|
||||
},
|
||||
lastUsed: {
|
||||
id: "settings.pats.token.last-used",
|
||||
defaultMessage: "Last used {ago}",
|
||||
},
|
||||
neverUsed: {
|
||||
id: "settings.pats.token.never-used",
|
||||
defaultMessage: "Never used",
|
||||
},
|
||||
expiresIn: {
|
||||
id: "settings.pats.token.expires-in",
|
||||
defaultMessage: "Expires {inTime}",
|
||||
},
|
||||
expiredAgo: {
|
||||
id: "settings.pats.token.expired-ago",
|
||||
defaultMessage: "Expired {ago}",
|
||||
},
|
||||
});
|
||||
edit: {
|
||||
id: 'settings.pats.token.action.edit',
|
||||
defaultMessage: 'Edit token',
|
||||
},
|
||||
revoke: {
|
||||
id: 'settings.pats.token.action.revoke',
|
||||
defaultMessage: 'Revoke token',
|
||||
},
|
||||
lastUsed: {
|
||||
id: 'settings.pats.token.last-used',
|
||||
defaultMessage: 'Last used {ago}',
|
||||
},
|
||||
neverUsed: {
|
||||
id: 'settings.pats.token.never-used',
|
||||
defaultMessage: 'Never used',
|
||||
},
|
||||
expiresIn: {
|
||||
id: 'settings.pats.token.expires-in',
|
||||
defaultMessage: 'Expires {inTime}',
|
||||
},
|
||||
expiredAgo: {
|
||||
id: 'settings.pats.token.expired-ago',
|
||||
defaultMessage: 'Expired {ago}',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: `${formatMessage(commonSettingsMessages.pats)} - Modrinth`,
|
||||
});
|
||||
title: `${formatMessage(commonSettingsMessages.pats)} - Modrinth`,
|
||||
})
|
||||
|
||||
const data = useNuxtApp();
|
||||
const { scopesToLabels } = useScopes();
|
||||
const patModal = ref();
|
||||
const data = useNuxtApp()
|
||||
const { scopesToLabels } = useScopes()
|
||||
const patModal = ref()
|
||||
|
||||
const editPatIndex = ref(null);
|
||||
const editPatIndex = ref(null)
|
||||
|
||||
const name = ref(null);
|
||||
const scopesVal = ref(BigInt(0));
|
||||
const expires = ref(null);
|
||||
const name = ref(null)
|
||||
const scopesVal = ref(BigInt(0))
|
||||
const expires = ref(null)
|
||||
|
||||
const deletePatIndex = ref(null);
|
||||
const deletePatIndex = ref(null)
|
||||
|
||||
const loading = ref(false);
|
||||
const loading = ref(false)
|
||||
|
||||
const { data: pats, refresh } = await useAsyncData("pat", () => useBaseFetch("pat"));
|
||||
const { data: pats, refresh } = await useAsyncData('pat', () => useBaseFetch('pat'))
|
||||
|
||||
async function createPat() {
|
||||
startLoading();
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await useBaseFetch("pat", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
});
|
||||
pats.value.push(res);
|
||||
patModal.value.hide();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
loading.value = false;
|
||||
stopLoading();
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await useBaseFetch('pat', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
})
|
||||
pats.value.push(res)
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function editPat() {
|
||||
startLoading();
|
||||
loading.value = true;
|
||||
try {
|
||||
await useBaseFetch(`pat/${pats.value[editPatIndex.value].id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
patModal.value.hide();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
loading.value = false;
|
||||
stopLoading();
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
await useBaseFetch(`pat/${pats.value[editPatIndex.value].id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
})
|
||||
await refresh()
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removePat(id) {
|
||||
startLoading();
|
||||
try {
|
||||
pats.value = pats.value.filter((x) => x.id !== id);
|
||||
await useBaseFetch(`pat/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
stopLoading();
|
||||
startLoading()
|
||||
try {
|
||||
pats.value = pats.value.filter((x) => x.id !== id)
|
||||
await useBaseFetch(`pat/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,252 +1,252 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="card">
|
||||
<h2 class="text-2xl">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-4">
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #docs-link="{ children }">
|
||||
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<label>
|
||||
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
|
||||
</label>
|
||||
<div class="avatar-changer">
|
||||
<Avatar
|
||||
:src="previewImage ? previewImage : avatarUrl"
|
||||
size="md"
|
||||
circle
|
||||
:alt="auth.user.username"
|
||||
/>
|
||||
<div class="input-stack">
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
class="btn"
|
||||
:prompt="formatMessage(commonMessages.uploadImageButton)"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<Button v-if="avatarUrl !== null" :action="removePreviewImage">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.removeImageButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="previewImage"
|
||||
:action="
|
||||
() => {
|
||||
icon = null;
|
||||
previewImage = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<UndoIcon />
|
||||
{{ formatMessage(messages.profilePictureReset) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<label for="username-field">
|
||||
<span class="label__title">{{ formatMessage(messages.usernameTitle) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input id="username-field" v-model="username" type="text" />
|
||||
<label for="bio-field">
|
||||
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.bioDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea id="bio-field" v-model="bio" type="text" />
|
||||
<div v-if="hasUnsavedChanges" class="input-group">
|
||||
<Button color="primary" :action="() => saveChanges()">
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</Button>
|
||||
<Button :action="() => cancel()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="input-group">
|
||||
<Button disabled color="primary" :action="() => saveChanges()">
|
||||
<SaveIcon />
|
||||
{{
|
||||
saved
|
||||
? formatMessage(commonMessages.changesSavedLabel)
|
||||
: formatMessage(commonMessages.saveChangesButton)
|
||||
}}
|
||||
</Button>
|
||||
<Button :link="`/user/${auth.user.username}`">
|
||||
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<section class="card">
|
||||
<h2 class="text-2xl">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-4">
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #docs-link="{ children }">
|
||||
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<label>
|
||||
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
|
||||
</label>
|
||||
<div class="avatar-changer">
|
||||
<Avatar
|
||||
:src="previewImage ? previewImage : avatarUrl"
|
||||
size="md"
|
||||
circle
|
||||
:alt="auth.user.username"
|
||||
/>
|
||||
<div class="input-stack">
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
class="btn"
|
||||
:prompt="formatMessage(commonMessages.uploadImageButton)"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<Button v-if="avatarUrl !== null" :action="removePreviewImage">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.removeImageButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="previewImage"
|
||||
:action="
|
||||
() => {
|
||||
icon = null
|
||||
previewImage = null
|
||||
}
|
||||
"
|
||||
>
|
||||
<UndoIcon />
|
||||
{{ formatMessage(messages.profilePictureReset) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<label for="username-field">
|
||||
<span class="label__title">{{ formatMessage(messages.usernameTitle) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input id="username-field" v-model="username" type="text" />
|
||||
<label for="bio-field">
|
||||
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.bioDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea id="bio-field" v-model="bio" type="text" />
|
||||
<div v-if="hasUnsavedChanges" class="input-group">
|
||||
<Button color="primary" :action="() => saveChanges()">
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</Button>
|
||||
<Button :action="() => cancel()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="input-group">
|
||||
<Button disabled color="primary" :action="() => saveChanges()">
|
||||
<SaveIcon />
|
||||
{{
|
||||
saved
|
||||
? formatMessage(commonMessages.changesSavedLabel)
|
||||
: formatMessage(commonMessages.saveChangesButton)
|
||||
}}
|
||||
</Button>
|
||||
<Button :link="`/user/${auth.user.username}`">
|
||||
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SaveIcon, TrashIcon, UndoIcon, UploadIcon, UserIcon, XIcon } from "@modrinth/assets";
|
||||
import { Avatar, Button, commonMessages, FileInput, injectNotificationManager } from "@modrinth/ui";
|
||||
import { SaveIcon, TrashIcon, UndoIcon, UploadIcon, UserIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, Button, commonMessages, FileInput, injectNotificationManager } from '@modrinth/ui'
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const { formatMessage } = useVIntl();
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
useHead({
|
||||
title: "Profile settings - Modrinth",
|
||||
});
|
||||
title: 'Profile settings - Modrinth',
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: "settings.profile.profile-info",
|
||||
defaultMessage: "Profile information",
|
||||
},
|
||||
description: {
|
||||
id: "settings.profile.description",
|
||||
defaultMessage:
|
||||
"Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.",
|
||||
},
|
||||
profilePicture: {
|
||||
id: "settings.profile.profile-picture.title",
|
||||
defaultMessage: "Profile picture",
|
||||
},
|
||||
profilePictureReset: {
|
||||
id: "settings.profile.profile-picture.reset",
|
||||
defaultMessage: "Reset",
|
||||
},
|
||||
usernameTitle: {
|
||||
id: "settings.profile.username.title",
|
||||
defaultMessage: "Username",
|
||||
},
|
||||
usernameDescription: {
|
||||
id: "settings.profile.username.description",
|
||||
defaultMessage: "A unique case-insensitive name to identify your profile.",
|
||||
},
|
||||
bioTitle: {
|
||||
id: "settings.profile.bio.title",
|
||||
defaultMessage: "Bio",
|
||||
},
|
||||
bioDescription: {
|
||||
id: "settings.profile.bio.description",
|
||||
defaultMessage: "A short description to tell everyone a little bit about you.",
|
||||
},
|
||||
});
|
||||
title: {
|
||||
id: 'settings.profile.profile-info',
|
||||
defaultMessage: 'Profile information',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.profile.description',
|
||||
defaultMessage:
|
||||
'Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.',
|
||||
},
|
||||
profilePicture: {
|
||||
id: 'settings.profile.profile-picture.title',
|
||||
defaultMessage: 'Profile picture',
|
||||
},
|
||||
profilePictureReset: {
|
||||
id: 'settings.profile.profile-picture.reset',
|
||||
defaultMessage: 'Reset',
|
||||
},
|
||||
usernameTitle: {
|
||||
id: 'settings.profile.username.title',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
usernameDescription: {
|
||||
id: 'settings.profile.username.description',
|
||||
defaultMessage: 'A unique case-insensitive name to identify your profile.',
|
||||
},
|
||||
bioTitle: {
|
||||
id: 'settings.profile.bio.title',
|
||||
defaultMessage: 'Bio',
|
||||
},
|
||||
bioDescription: {
|
||||
id: 'settings.profile.bio.description',
|
||||
defaultMessage: 'A short description to tell everyone a little bit about you.',
|
||||
},
|
||||
})
|
||||
|
||||
const auth = await useAuth();
|
||||
const auth = await useAuth()
|
||||
|
||||
const username = ref(auth.value.user.username);
|
||||
const bio = ref(auth.value.user.bio);
|
||||
const avatarUrl = ref(auth.value.user.avatar_url);
|
||||
const icon = shallowRef(null);
|
||||
const previewImage = shallowRef(null);
|
||||
const pendingAvatarDeletion = ref(false);
|
||||
const saved = ref(false);
|
||||
const username = ref(auth.value.user.username)
|
||||
const bio = ref(auth.value.user.bio)
|
||||
const avatarUrl = ref(auth.value.user.avatar_url)
|
||||
const icon = shallowRef(null)
|
||||
const previewImage = shallowRef(null)
|
||||
const pendingAvatarDeletion = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
username.value !== auth.value.user.username ||
|
||||
bio.value !== auth.value.user.bio ||
|
||||
previewImage.value,
|
||||
);
|
||||
() =>
|
||||
username.value !== auth.value.user.username ||
|
||||
bio.value !== auth.value.user.bio ||
|
||||
previewImage.value,
|
||||
)
|
||||
|
||||
function showPreviewImage(files) {
|
||||
const reader = new FileReader();
|
||||
icon.value = files[0];
|
||||
reader.readAsDataURL(icon.value);
|
||||
reader.onload = (event) => {
|
||||
previewImage.value = event.target.result;
|
||||
};
|
||||
const reader = new FileReader()
|
||||
icon.value = files[0]
|
||||
reader.readAsDataURL(icon.value)
|
||||
reader.onload = (event) => {
|
||||
previewImage.value = event.target.result
|
||||
}
|
||||
}
|
||||
|
||||
function removePreviewImage() {
|
||||
pendingAvatarDeletion.value = true;
|
||||
previewImage.value = "https://cdn.modrinth.com/placeholder.png";
|
||||
pendingAvatarDeletion.value = true
|
||||
previewImage.value = 'https://cdn.modrinth.com/placeholder.png'
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
icon.value = null;
|
||||
previewImage.value = null;
|
||||
pendingAvatarDeletion.value = false;
|
||||
username.value = auth.value.user.username;
|
||||
bio.value = auth.value.user.bio;
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
pendingAvatarDeletion.value = false
|
||||
username.value = auth.value.user.username
|
||||
bio.value = auth.value.user.bio
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
startLoading();
|
||||
try {
|
||||
if (pendingAvatarDeletion.value) {
|
||||
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
pendingAvatarDeletion.value = false;
|
||||
previewImage.value = null;
|
||||
}
|
||||
startLoading()
|
||||
try {
|
||||
if (pendingAvatarDeletion.value) {
|
||||
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
pendingAvatarDeletion.value = false
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
if (icon.value) {
|
||||
await useBaseFetch(
|
||||
`user/${auth.value.user.id}/icon?ext=${
|
||||
icon.value.type.split("/")[icon.value.type.split("/").length - 1]
|
||||
}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: icon.value,
|
||||
},
|
||||
);
|
||||
icon.value = null;
|
||||
previewImage.value = null;
|
||||
}
|
||||
if (icon.value) {
|
||||
await useBaseFetch(
|
||||
`user/${auth.value.user.id}/icon?ext=${
|
||||
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
|
||||
}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: icon.value,
|
||||
},
|
||||
)
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
const body = {};
|
||||
const body = {}
|
||||
|
||||
if (auth.value.user.username !== username.value) {
|
||||
body.username = username.value;
|
||||
}
|
||||
if (auth.value.user.username !== username.value) {
|
||||
body.username = username.value
|
||||
}
|
||||
|
||||
if (auth.value.user.bio !== bio.value) {
|
||||
body.bio = bio.value;
|
||||
}
|
||||
if (auth.value.user.bio !== bio.value) {
|
||||
body.bio = bio.value
|
||||
}
|
||||
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
});
|
||||
await useAuth(auth.value.token);
|
||||
avatarUrl.value = auth.value.user.avatar_url;
|
||||
saved.value = true;
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: "An error occurred",
|
||||
text: err
|
||||
? err.data
|
||||
? err.data.description
|
||||
? err.data.description
|
||||
: err.data
|
||||
: err
|
||||
: "aaaaahhh",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
stopLoading();
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
await useAuth(auth.value.token)
|
||||
avatarUrl.value = auth.value.user.avatar_url
|
||||
saved.value = true
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: err
|
||||
? err.data
|
||||
? err.data.description
|
||||
? err.data.description
|
||||
: err.data
|
||||
: err
|
||||
: 'aaaaahhh',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.avatar-changer {
|
||||
display: flex;
|
||||
gap: var(--gap-lg);
|
||||
margin-top: var(--gap-md);
|
||||
display: flex;
|
||||
gap: var(--gap-lg);
|
||||
margin-top: var(--gap-md);
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 6rem;
|
||||
width: 40rem;
|
||||
margin-bottom: var(--gap-lg);
|
||||
height: 6rem;
|
||||
width: 40rem;
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,148 +1,148 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.sessions) }}</h2>
|
||||
<p class="preserve-lines">
|
||||
{{ formatMessage(messages.sessionsDescription) }}
|
||||
</p>
|
||||
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session mt-4">
|
||||
<div>
|
||||
<div>
|
||||
<strong>
|
||||
{{ session.os ?? formatMessage(messages.unknownOsLabel) }} ⋅
|
||||
{{ session.platform ?? formatMessage(messages.unknownPlatformLabel) }} ⋅
|
||||
{{ session.ip }}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="session.city">{{ session.city }}, {{ session.country }} ⋅ </template>
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(session.last_login),
|
||||
time: new Date(session.last_login),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.lastAccessedAgoLabel, {
|
||||
ago: formatRelativeTime(session.last_login),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(session.created),
|
||||
time: new Date(session.created),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.createdAgoLabel, {
|
||||
ago: formatRelativeTime(session.created),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<i v-if="session.current">{{ formatMessage(messages.currentSessionLabel) }}</i>
|
||||
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
|
||||
<XIcon /> {{ formatMessage(messages.revokeSessionButton) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.sessions) }}</h2>
|
||||
<p class="preserve-lines">
|
||||
{{ formatMessage(messages.sessionsDescription) }}
|
||||
</p>
|
||||
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session mt-4">
|
||||
<div>
|
||||
<div>
|
||||
<strong>
|
||||
{{ session.os ?? formatMessage(messages.unknownOsLabel) }} ⋅
|
||||
{{ session.platform ?? formatMessage(messages.unknownPlatformLabel) }} ⋅
|
||||
{{ session.ip }}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="session.city">{{ session.city }}, {{ session.country }} ⋅ </template>
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(session.last_login),
|
||||
time: new Date(session.last_login),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.lastAccessedAgoLabel, {
|
||||
ago: formatRelativeTime(session.last_login),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(session.created),
|
||||
time: new Date(session.created),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.createdAgoLabel, {
|
||||
ago: formatRelativeTime(session.created),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<i v-if="session.current">{{ formatMessage(messages.currentSessionLabel) }}</i>
|
||||
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
|
||||
<XIcon /> {{ formatMessage(messages.revokeSessionButton) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon } from "@modrinth/assets";
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
commonSettingsMessages,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
} from "@modrinth/ui";
|
||||
commonMessages,
|
||||
commonSettingsMessages,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const { formatMessage } = useVIntl();
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const messages = defineMessages({
|
||||
currentSessionLabel: {
|
||||
id: "settings.sessions.current-session",
|
||||
defaultMessage: "Current session",
|
||||
},
|
||||
revokeSessionButton: {
|
||||
id: "settings.sessions.action.revoke-session",
|
||||
defaultMessage: "Revoke session",
|
||||
},
|
||||
createdAgoLabel: {
|
||||
id: "settings.sessions.created-ago",
|
||||
defaultMessage: "Created {ago}",
|
||||
},
|
||||
sessionsDescription: {
|
||||
id: "settings.sessions.description",
|
||||
defaultMessage:
|
||||
"Here are all the devices that are currently logged in with your Modrinth account. You can log out of each one individually.\n\nIf you see an entry you don't recognize, log out of that device and change your Modrinth account password immediately.",
|
||||
},
|
||||
lastAccessedAgoLabel: {
|
||||
id: "settings.sessions.last-accessed-ago",
|
||||
defaultMessage: "Last accessed {ago}",
|
||||
},
|
||||
unknownOsLabel: {
|
||||
id: "settings.sessions.unknown-os",
|
||||
defaultMessage: "Unknown OS",
|
||||
},
|
||||
unknownPlatformLabel: {
|
||||
id: "settings.sessions.unknown-platform",
|
||||
defaultMessage: "Unknown platform",
|
||||
},
|
||||
});
|
||||
currentSessionLabel: {
|
||||
id: 'settings.sessions.current-session',
|
||||
defaultMessage: 'Current session',
|
||||
},
|
||||
revokeSessionButton: {
|
||||
id: 'settings.sessions.action.revoke-session',
|
||||
defaultMessage: 'Revoke session',
|
||||
},
|
||||
createdAgoLabel: {
|
||||
id: 'settings.sessions.created-ago',
|
||||
defaultMessage: 'Created {ago}',
|
||||
},
|
||||
sessionsDescription: {
|
||||
id: 'settings.sessions.description',
|
||||
defaultMessage:
|
||||
"Here are all the devices that are currently logged in with your Modrinth account. You can log out of each one individually.\n\nIf you see an entry you don't recognize, log out of that device and change your Modrinth account password immediately.",
|
||||
},
|
||||
lastAccessedAgoLabel: {
|
||||
id: 'settings.sessions.last-accessed-ago',
|
||||
defaultMessage: 'Last accessed {ago}',
|
||||
},
|
||||
unknownOsLabel: {
|
||||
id: 'settings.sessions.unknown-os',
|
||||
defaultMessage: 'Unknown OS',
|
||||
},
|
||||
unknownPlatformLabel: {
|
||||
id: 'settings.sessions.unknown-platform',
|
||||
defaultMessage: 'Unknown platform',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(commonSettingsMessages.sessions)} - Modrinth`,
|
||||
});
|
||||
title: () => `${formatMessage(commonSettingsMessages.sessions)} - Modrinth`,
|
||||
})
|
||||
|
||||
const { data: sessions, refresh } = await useAsyncData("session/list", () =>
|
||||
useBaseFetch("session/list"),
|
||||
);
|
||||
const { data: sessions, refresh } = await useAsyncData('session/list', () =>
|
||||
useBaseFetch('session/list'),
|
||||
)
|
||||
|
||||
async function revokeSession(id) {
|
||||
startLoading();
|
||||
try {
|
||||
sessions.value = sessions.value.filter((x) => x.id !== id);
|
||||
await useBaseFetch(`session/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
stopLoading();
|
||||
startLoading()
|
||||
try {
|
||||
sessions.value = sessions.value.filter((x) => x.id !== id)
|
||||
await useBaseFetch(`session/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user