mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
menu refactor + web account switcher (#7307)
* add account switcher, migrate context menus to overflow menu system, update version filter controls, only pad instance icons * app account switcher, improve some context menus, theme stuff * prepr * handle reauthentication * fix a couple sign in issues, admin page cleanup, fix org status badges, fix official account badge, open in local keybind, publish plus icons * rename generic menus to ButtonMenu (& types) * fix hydration issue w/ dropdowns + middleware error
This commit is contained in:
@@ -70,9 +70,9 @@ import {
|
||||
import { ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
type ButtonMenuOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
type OverflowMenuOption,
|
||||
PageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderBadgeItem,
|
||||
@@ -133,7 +133,7 @@ const emit = defineEmits<{
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatNumber = useFormatNumber()
|
||||
|
||||
const moreActions = computed<OverflowMenuOption[]>(() => [
|
||||
const moreActions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'manage-projects',
|
||||
label: formatMessage(messages.manageProjects),
|
||||
|
||||
@@ -104,12 +104,12 @@ import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
type ButtonMenuOption,
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
type OverflowMenuOption,
|
||||
ServersPromo,
|
||||
SplitButton,
|
||||
truncatedTooltip,
|
||||
@@ -350,7 +350,7 @@ const showDependencyDownloadActions = computed(
|
||||
hasRecommendedDownloadFiles.value),
|
||||
)
|
||||
|
||||
const downloadWithRecommendedOptions = computed<OverflowMenuOption[]>(() => [
|
||||
const downloadWithRecommendedOptions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'download-with-recommended',
|
||||
label: formatMessage(messages.downloadWithRecommended),
|
||||
|
||||
@@ -47,7 +47,12 @@
|
||||
class="flex flex-col items-center gap-4 rounded-3xl border border-solid border-surface-5 bg-surface-3 p-6"
|
||||
>
|
||||
<div class="flex w-full items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<Avatar :src="instance.icon" :alt="instance.name" size="48px" />
|
||||
<Avatar
|
||||
:src="instance.icon"
|
||||
:alt="instance.name"
|
||||
size="48px"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium leading-4 text-primary">
|
||||
|
||||
@@ -10,10 +10,20 @@
|
||||
If the app doesn’t open, use the button below to finish signing in.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="colored" color="brand" class="" @click="sendLauncherCallback">
|
||||
{{ formatMessage(messages.returnToLauncherButton) }}
|
||||
<RightArrowIcon />
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
class="!w-full !justify-center"
|
||||
@click="sendLauncherCallback"
|
||||
>
|
||||
{{ formatMessage(messages.returnToLauncherButton) }}
|
||||
<RightArrowIcon />
|
||||
</Button>
|
||||
<ButtonLink to="/" class="!w-full !justify-center">
|
||||
{{ formatMessage(messages.goToWebsiteButton) }}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -46,111 +56,133 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="text-center text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.signInWithLabel) }}
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<ButtonLink
|
||||
v-for="provider in oauthProviders"
|
||||
:key="provider.id"
|
||||
class="relative w-full !justify-center overflow-visible"
|
||||
:class="{
|
||||
'!border !border-[var(--color-green)]': lastSignInProvider === provider.id,
|
||||
}"
|
||||
:href="getAuthUrl(provider.id, redirectTarget)"
|
||||
:aria-label="formatMessage(messages.continueWithProvider, { provider: provider.name })"
|
||||
@click="onOAuthProviderClick(provider.id)"
|
||||
>
|
||||
<component :is="provider.icon" />
|
||||
<span>{{ provider.name }}</span>
|
||||
<span
|
||||
v-if="lastSignInProvider === provider.id"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</ButtonLink>
|
||||
<Button
|
||||
class="relative !w-full !justify-center overflow-visible sm:col-span-2"
|
||||
:class="{ '!border !border-[var(--color-green)]': lastSignInProvider === 'passkey' }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onPasskeySignIn"
|
||||
@keydown.enter="onPasskeySignIn"
|
||||
>
|
||||
<UserKeyIcon />
|
||||
<span class="ml-1">{{ formatMessage(messages.continueWithPasskey) }}</span>
|
||||
<span v-if="lastSignInProvider === 'passkey'" class="oauth-provider-last-sign-in-badge">
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div class="h-px w-full bg-surface-5"></div>
|
||||
|
||||
<section class="mx-auto flex w-full flex-col gap-2.5">
|
||||
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="emailModel"
|
||||
:icon="MailIcon"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="username"
|
||||
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="passwordModel"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<HCaptcha
|
||||
v-if="globals?.captcha_enabled && emailModel && passwordModel"
|
||||
:ref="onSetCaptchaRef"
|
||||
v-model="tokenModel"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
class="!w-full"
|
||||
:disabled="globals?.captcha_enabled ? !tokenModel : false"
|
||||
@click="onPasswordSignIn()"
|
||||
>
|
||||
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2.5 !text-base">
|
||||
<NuxtLink
|
||||
class="text-link"
|
||||
:to="{
|
||||
path: '/auth/reset-password',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(messages.forgotPasswordLabel) }}
|
||||
</NuxtLink>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-surface-5" />
|
||||
<NuxtLink
|
||||
class="inline text-link"
|
||||
:to="{
|
||||
path: '/auth/sign-up',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(messages.createAccountLabel) }}
|
||||
</NuxtLink>
|
||||
<template v-if="accounts.length && !addingAccount">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<div class="text-center text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.chooseAccountLabel) }}
|
||||
</div>
|
||||
<AccountChoiceList
|
||||
:accounts="accounts"
|
||||
:add-account-label="formatMessage(messages.addAccountLabel)"
|
||||
@select="emit('select', $event)"
|
||||
@add="addingAccount = true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.signInWithLabel) }}
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<ButtonLink
|
||||
v-for="provider in oauthProviders"
|
||||
:key="provider.id"
|
||||
class="relative w-full !justify-center overflow-visible"
|
||||
:class="{
|
||||
'!border !border-[var(--color-green)]': lastSignInOAuthProvider === provider.id,
|
||||
}"
|
||||
:href="getAuthUrl(provider.id, redirectTarget)"
|
||||
:aria-label="
|
||||
formatMessage(messages.continueWithProvider, { provider: provider.name })
|
||||
"
|
||||
@click="onOAuthProviderClick(provider.id)"
|
||||
>
|
||||
<component :is="provider.icon" />
|
||||
<span>{{ provider.name }}</span>
|
||||
<span
|
||||
v-if="lastSignInOAuthProvider === provider.id"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</ButtonLink>
|
||||
<Button
|
||||
class="relative !w-full !justify-center overflow-visible sm:col-span-2"
|
||||
:class="{
|
||||
'!border !border-[var(--color-green)]': lastSignInOAuthProvider === 'passkey',
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onPasskeySignIn"
|
||||
@keydown.enter="onPasskeySignIn"
|
||||
>
|
||||
<UserKeyIcon />
|
||||
<span class="ml-1">{{ formatMessage(messages.continueWithPasskey) }}</span>
|
||||
<span
|
||||
v-if="lastSignInOAuthProvider === 'passkey'"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div class="h-px w-full bg-surface-5"></div>
|
||||
|
||||
<section class="mx-auto flex w-full flex-col gap-2.5">
|
||||
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="emailModel"
|
||||
:icon="MailIcon"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="username"
|
||||
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="passwordModel"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<HCaptcha
|
||||
v-if="globals?.captcha_enabled && emailModel && passwordModel"
|
||||
:ref="onSetCaptchaRef"
|
||||
v-model="tokenModel"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
class="!w-full"
|
||||
:disabled="globals?.captcha_enabled ? !tokenModel : false"
|
||||
@click="onPasswordSignIn()"
|
||||
>
|
||||
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2.5 !text-base">
|
||||
<NuxtLink
|
||||
class="text-link"
|
||||
:to="{
|
||||
path: '/auth/reset-password',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(messages.forgotPasswordLabel) }}
|
||||
</NuxtLink>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-surface-5" />
|
||||
<NuxtLink
|
||||
class="inline text-link"
|
||||
:to="{
|
||||
path: '/auth/sign-up',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(messages.createAccountLabel) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -169,17 +201,26 @@ import {
|
||||
SteamColorIcon,
|
||||
UserKeyIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, ButtonLink, commonMessages, defineMessages, Input, useVIntl } from '@modrinth/ui'
|
||||
import {
|
||||
type AccountChoice,
|
||||
AccountChoiceList,
|
||||
Button,
|
||||
ButtonLink,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
Input,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
|
||||
import {
|
||||
getAuthUrl,
|
||||
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
} from '@/composables/auth.ts'
|
||||
} from '@/composables/accounts.ts'
|
||||
import { getAuthUrl } from '@/composables/auth.ts'
|
||||
|
||||
const oauthProviders = [
|
||||
{ id: 'discord', name: 'Discord', icon: DiscordColorIcon },
|
||||
@@ -207,6 +248,7 @@ interface Props {
|
||||
onTwoFactorSignIn?: () => void
|
||||
onPasskeySignIn?: () => void
|
||||
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
|
||||
accounts?: AccountChoice[]
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -219,8 +261,15 @@ const {
|
||||
onTwoFactorSignIn = () => {},
|
||||
onPasskeySignIn = () => {},
|
||||
onSetCaptchaRef = undefined,
|
||||
accounts = [],
|
||||
} = defineProps<Props>()
|
||||
|
||||
const addingAccount = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [account: AccountChoice]
|
||||
}>()
|
||||
|
||||
const emailModel = defineModel<string>('email', { default: '' })
|
||||
const passwordModel = defineModel<string>('password', { default: '' })
|
||||
const tokenModel = defineModel<string>('token', { default: '' })
|
||||
@@ -238,7 +287,6 @@ const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
const lastSignInProvider = computed(() => lastSignInOAuthProvider.value)
|
||||
const onOAuthProviderClick = (provider: AuthProvider) => {
|
||||
pendingSignInOAuthProvider.value = provider
|
||||
}
|
||||
@@ -266,6 +314,14 @@ const messages = defineMessages({
|
||||
id: 'auth.sign-in.sign-in-with',
|
||||
defaultMessage: 'Sign into Modrinth',
|
||||
},
|
||||
chooseAccountLabel: {
|
||||
id: 'auth.sign-in.choose-account',
|
||||
defaultMessage: 'Choose an account to use in Modrinth App',
|
||||
},
|
||||
addAccountLabel: {
|
||||
id: 'auth.sign-in.add-account',
|
||||
defaultMessage: 'Add account',
|
||||
},
|
||||
twoFactorCodeInputPlaceholder: {
|
||||
id: 'auth.sign-in.2fa.placeholder',
|
||||
defaultMessage: 'Enter code...',
|
||||
@@ -308,6 +364,10 @@ const messages = defineMessages({
|
||||
id: 'auth.sign-in.launcher.complete.return-button',
|
||||
defaultMessage: 'Open Modrinth App',
|
||||
},
|
||||
goToWebsiteButton: {
|
||||
id: 'auth.sign-in.launcher.complete.go-to-website',
|
||||
defaultMessage: 'Go to Modrinth.com',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -152,7 +152,8 @@ import {
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import { getAuthUrl, PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY } from '@/composables/auth.ts'
|
||||
import { PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY } from '@/composables/accounts.ts'
|
||||
import { getAuthUrl } from '@/composables/auth.ts'
|
||||
|
||||
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft'
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
:src="reportItemAvatarUrl"
|
||||
:circle="report.item_type === 'user'"
|
||||
size="4rem"
|
||||
:pad-transparent-corners="report.item_type === 'shared-instance'"
|
||||
:class="[
|
||||
'flex-shrink-0 border border-surface-5 bg-surface-4 !shadow-none',
|
||||
report.item_type !== 'user' && 'rounded-2xl',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/modera
|
||||
import { Button, ButtonLink, IconButton, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
type ButtonMenuOption,
|
||||
Collapsible,
|
||||
CollapsibleRegion,
|
||||
commonMessages,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
type OverflowMenuOption,
|
||||
Toggle,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
@@ -116,7 +116,7 @@ const isProjectApproved = computed(() => {
|
||||
})
|
||||
|
||||
const isLoadingStatusAction = ref(false)
|
||||
const projectStatusActions = computed<OverflowMenuOption[]>(() => [
|
||||
const projectStatusActions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'approve',
|
||||
label: 'Approve',
|
||||
|
||||
@@ -363,7 +363,13 @@ function formattedLoader(version: SharedInstanceReportVersion) {
|
||||
:key="instance.id"
|
||||
class="flex min-w-0 items-center gap-2 rounded-xl bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar :src="instance.icon_url" :tint-by="instance.id" size="2.5rem" no-shadow />
|
||||
<Avatar
|
||||
:src="instance.icon_url"
|
||||
:tint-by="instance.id"
|
||||
size="2.5rem"
|
||||
no-shadow
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
size="xs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div v-else class="backed-svg" :class="{ raised: raised }">
|
||||
<BoxesIcon />
|
||||
|
||||
@@ -95,10 +95,10 @@ import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/ass
|
||||
import type { QuickReply } from '@modrinth/moderation'
|
||||
import { Button, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
type ButtonMenuOption,
|
||||
CopyCode,
|
||||
injectNotificationManager,
|
||||
MarkdownEditor,
|
||||
type OverflowMenuOption,
|
||||
} from '@modrinth/ui'
|
||||
import type { Thread, User } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -110,7 +110,7 @@ import ThreadMessage from './ThreadMessage.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
|
||||
const replies = props.quickReplies
|
||||
const context = props.quickReplyContext
|
||||
|
||||
@@ -127,7 +127,7 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
id: reply.label,
|
||||
label: reply.label,
|
||||
action: () => handleQuickReply(reply, context),
|
||||
}) as OverflowMenuOption,
|
||||
}) as ButtonMenuOption,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user