Files
AstralRinth/apps/frontend/src/layouts/default.vue

1550 lines
40 KiB
Vue

<template>
<div class="pointer-events-none fixed inset-0 z-[-1]">
<div id="fixed-background-teleport" class="relative"></div>
</div>
<div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div>
</div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
:style="{ '--_r-count': rCount }"
:class="{ threshold: rCount > 20, 'rings-expand': rCount >= 40 }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight text-9xl font-extrabold text-contrast"
>
?
</div>
</div>
</div>
</div>
<div
ref="main_page"
class="layout"
:class="{
'expanded-mobile-nav': isBrowseMenuOpen,
'modrinth-parent__no-modal-blurs': !cosmetics.advancedRendering,
}"
>
<RussiaBanner v-if="isRussia" />
<TaxIdMismatchBanner v-if="showTinMismatchBanner" />
<TaxComplianceBanner v-if="showTaxComplianceBanner" />
<VerifyEmailBanner
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
:has-email="auth?.user?.email"
/>
<SubscriptionPaymentFailedBanner
v-if="
user.subscriptions.some((x) => x.status === 'payment-failed') &&
route.path !== '/settings/billing'
"
/>
<StagingBanner v-if="config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com')" />
<GeneratedStateErrorsBanner
:errors="generatedStateErrors"
:api-url="config.public.apiBaseUrl"
/>
<header
class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-6 py-4 lg:grid-cols-[auto_1fr_auto]"
>
<div>
<NuxtLink
to="/"
:aria-label="formatMessage(messages.modrinthHomePage)"
class="group hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]"
>
<TextLogo
aria-hidden="true"
class="h-7 w-auto text-contrast transition-transform group-active:scale-[0.98]"
/>
</NuxtLink>
</div>
<div
:class="`col-span-2 row-start-2 flex flex-wrap justify-center ${flags.projectTypesPrimaryNav ? 'gap-2' : 'gap-4'} lg:col-span-1 lg:row-start-auto`"
>
<template v-if="flags.projectTypesPrimaryNav">
<ButtonStyled
type="transparent"
:highlighted="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
:highlighted-style="
route.name === 'discover-mods' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/discover/mods">
<BoxIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
"
:highlighted-style="
route.name === 'discover-resourcepacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/discover/resourcepacks">
<PaintbrushIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
"
:highlighted-style="
route.name === 'discover-datapacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/discover/datapacks">
<BracesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
:highlighted-style="
route.name === 'discover-modpacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/discover/modpacks">
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
:highlighted-style="
route.name === 'discover-shaders' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/discover/shaders">
<GlassesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
:highlighted-style="
route.name === 'discover-plugins' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/discover/plugins">
<PlugIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
</nuxt-link>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled
type="transparent"
:highlighted="isDiscovering || isDiscoveringSubpage"
:highlighted-style="isDiscoveringSubpage ? 'main-nav-secondary' : 'main-nav-primary'"
>
<TeleportOverflowMenu
:options="[
{
id: 'mods',
action: '/discover/mods',
},
{
id: 'resourcepacks',
action: '/discover/resourcepacks',
},
{
id: 'datapacks',
action: '/discover/datapacks',
},
{
id: 'shaders',
action: '/discover/shaders',
},
{
id: 'modpacks',
action: '/discover/modpacks',
},
{
id: 'plugins',
action: '/discover/plugins',
},
{
id: 'servers',
action: '/discover/servers',
shown: flags.serverDiscovery,
},
]"
hoverable
>
<BoxIcon
v-if="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
"
aria-hidden="true"
/>
<BracesIcon
v-else-if="
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
aria-hidden="true"
/>
<PlugIcon
v-else-if="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
aria-hidden="true"
/>
<ServerIcon
v-else-if="route.name === 'discover-servers' || route.path.startsWith('/server/')"
aria-hidden="true"
/>
<CompassIcon v-else aria-hidden="true" />
<span class="hidden md:contents">{{
formatMessage(navMenuMessages.discoverContent)
}}</span>
<span class="contents md:hidden">{{ formatMessage(navMenuMessages.discover) }}</span>
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
<template #mods>
<BoxIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
</template>
<template #resourcepacks>
<PaintbrushIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
</template>
<template #datapacks>
<BracesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
</template>
<template #plugins>
<PlugIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
</template>
<template #shaders>
<GlassesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
</template>
<template #modpacks>
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</template>
<template #servers>
<ServerIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.server) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="
route.name?.startsWith('hosting') ||
(route.name?.startsWith('discover-') && !!route.query.sid)
"
:highlighted-style="
route.name === 'hosting' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/hosting">
<ServerIcon aria-hidden="true" />
{{ formatMessage(navMenuMessages.hostAServer) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled type="transparent" :highlighted="route.name === 'app'">
<nuxt-link to="/app">
<DownloadIcon aria-hidden="true" />
<span class="hidden md:contents">{{
formatMessage(navMenuMessages.getModrinthApp)
}}</span>
<span class="contents md:hidden">{{
formatMessage(navMenuMessages.modrinthApp)
}}</span>
</nuxt-link>
</ButtonStyled>
</template>
</div>
<div class="flex items-center gap-1">
<ButtonStyled type="transparent">
<OverflowMenu
v-if="auth.user && isStaff(auth.user)"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
position="bottom"
direction="left"
:dropdown-id="`${basePopoutId}-staff`"
:aria-label="formatMessage(messages.createNew)"
:options="[
{
id: 'review-projects',
color: 'orange',
link: '/moderation/',
},
{
id: 'tech-review',
color: 'orange',
link: '/moderation/technical-review',
},
{
id: 'review-reports',
color: 'orange',
link: '/moderation/reports',
},
{
divider: true,
},
{
id: 'file-lookup',
link: '/admin/file_lookup',
},
{
divider: true,
shown: isAdmin(auth.user),
},
{
id: 'user-lookup',
color: 'primary',
link: '/admin/user_email',
shown: isAdmin(auth.user),
},
{
id: 'affiliates',
color: 'primary',
link: '/admin/affiliates',
shown: isAdmin(auth.user),
},
{
id: 'servers-notices',
color: 'primary',
link: '/admin/servers/notices',
shown: isAdmin(auth.user),
},
{
id: 'servers-nodes',
color: 'primary',
action: (event) => $refs.modal_batch_credit.show(event),
shown: isAdmin(auth.user),
},
]"
>
<ModrinthIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #review-projects>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProjects) }}
</template>
<template #tech-review>
<ShieldAlertIcon aria-hidden="true" /> {{ formatMessage(messages.techReview) }}
</template>
<template #review-reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
</template>
<template #user-lookup>
<UserSearchIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
</template>
<template #file-lookup>
<FileIcon aria-hidden="true" /> {{ formatMessage(messages.fileLookup) }}
</template>
<template #servers-notices>
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
</template>
<template #affiliates>
<AffiliateIcon aria-hidden="true" /> {{ formatMessage(messages.manageAffiliates) }}
</template>
<template #servers-nodes>
<ServerIcon aria-hidden="true" /> Credit server nodes
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled type="transparent">
<OverflowMenu
v-if="auth.user"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
position="bottom"
direction="left"
:dropdown-id="`${basePopoutId}-create`"
:aria-label="formatMessage(messages.createNew)"
:options="[
{
id: 'new-project',
action: (event) => $refs.modal_creation.show(event),
},
{
id: 'new-collection',
action: (event) => $refs.modal_collection_creation.show(event),
},
{ divider: true },
{
id: 'new-organization',
action: (event) => $refs.modal_organization_creation.show(event),
},
]"
>
<PlusIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #new-project>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newProject) }}
</template>
<!-- <template #import-project> <BoxImportIcon /> Import project </template>-->
<template #new-collection>
<CollectionIcon aria-hidden="true" /> {{ formatMessage(messages.newCollection) }}
</template>
<template #new-organization>
<OrganizationIcon aria-hidden="true" /> {{ formatMessage(messages.newOrganization) }}
</template>
</OverflowMenu>
</ButtonStyled>
<OverflowMenu
v-if="auth.user"
:dropdown-id="`${basePopoutId}-user`"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
:options="userMenuOptions"
>
<Avatar :src="auth.user.avatar_url" aria-hidden="true" circle />
<DropdownIcon class="h-5 w-5 text-secondary" />
<template #profile>
<UserIcon aria-hidden="true" /> {{ formatMessage(messages.profile) }}
</template>
<template #notifications>
<BellIcon aria-hidden="true" /> {{ formatMessage(commonMessages.notificationsLabel) }}
</template>
<template #reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.activeReports) }}
</template>
<template #saved>
<LibraryIcon aria-hidden="true" /> {{ formatMessage(commonMessages.collectionsLabel) }}
</template>
<template #servers>
<ServerIcon aria-hidden="true" /> {{ formatMessage(messages.myServers) }}
</template>
<template #plus>
<ArrowBigUpDashIcon aria-hidden="true" />
{{ formatMessage(messages.upgradeToModrinthPlus) }}
</template>
<template #settings>
<SettingsIcon aria-hidden="true" /> {{ formatMessage(commonMessages.settingsLabel) }}
</template>
<template #flags>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.featureFlags) }}
</template>
<template #projects>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.projects) }}
</template>
<template #organizations>
<OrganizationIcon aria-hidden="true" /> {{ formatMessage(messages.organizations) }}
</template>
<template #affiliate-links>
<AffiliateIcon aria-hidden="true" />
{{ formatMessage(commonMessages.affiliateLinksButton) }}
</template>
<template #revenue>
<CurrencyIcon aria-hidden="true" /> {{ formatMessage(messages.revenue) }}
</template>
<template #analytics>
<ChartIcon aria-hidden="true" /> {{ formatMessage(messages.analytics) }}
</template>
<template #moderation>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(commonMessages.moderationLabel) }}
</template>
<template #sign-out>
<LogOutIcon aria-hidden="true" /> {{ formatMessage(commonMessages.signOutButton) }}
</template>
</OverflowMenu>
<template v-else>
<ButtonStyled color="brand">
<nuxt-link to="/auth/sign-in">
<LogInIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signInButton) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link :v-tooltip="formatMessage(commonMessages.settingsLabel)" to="/settings">
<SettingsIcon :aria-label="formatMessage(commonMessages.settingsLabel)" />
</nuxt-link>
</ButtonStyled>
</template>
</div>
</header>
<header class="mobile-navigation mobile-only">
<div
class="nav-menu nav-menu-browse"
:class="{ expanded: isBrowseMenuOpen }"
@focusin="isBrowseMenuOpen = true"
@focusout="isBrowseMenuOpen = false"
>
<div class="links cascade-links">
<NuxtLink
v-for="navRoute in navRoutes"
:key="navRoute.href"
:to="navRoute.href"
class="iconified-button"
>
{{ navRoute.label }}
</NuxtLink>
</div>
</div>
<div
class="nav-menu nav-menu-mobile"
:class="{ expanded: isMobileMenuOpen }"
@focusin="isMobileMenuOpen = true"
@focusout="isMobileMenuOpen = false"
>
<div class="account-container">
<NuxtLink
v-if="auth.user"
:to="`/user/${auth.user.username}`"
class="iconified-button account-button"
>
<Avatar
:src="auth.user.avatar_url"
class="user-icon"
:alt="formatMessage(messages.yourAvatarAlt)"
aria-hidden="true"
circle
/>
<div class="account-text">
<div>@{{ auth.user.username }}</div>
<div>{{ formatMessage(commonMessages.visitYourProfile) }}</div>
</div>
</NuxtLink>
<nuxt-link v-else class="iconified-button brand-button" to="/auth/sign-in">
<LogInIcon aria-hidden="true" /> {{ formatMessage(commonMessages.signInButton) }}
</nuxt-link>
</div>
<div class="links">
<template v-if="auth.user">
<button class="iconified-button danger-button" @click="logoutUser()">
<LogOutIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signOutButton) }}
</button>
<button class="iconified-button" @click="$refs.modal_creation.show()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
<NuxtLink class="iconified-button" to="/dashboard/collections">
<LibraryIcon class="icon" />
{{ formatMessage(commonMessages.collectionsLabel) }}
</NuxtLink>
<NuxtLink class="iconified-button" to="/hosting/manage">
<ServerIcon class="icon" />
{{ formatMessage(commonMessages.serversLabel) }}
</NuxtLink>
<NuxtLink
v-if="auth.user.role === 'moderator' || auth.user.role === 'admin'"
class="iconified-button"
to="/moderation"
>
<ScaleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.moderationLabel) }}
</NuxtLink>
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
<ReportIcon aria-hidden="true" />
{{ formatMessage(messages.featureFlags) }}
</NuxtLink>
</template>
<NuxtLink class="iconified-button" to="/settings">
<SettingsIcon aria-hidden="true" />
{{ formatMessage(commonMessages.settingsLabel) }}
</NuxtLink>
<button class="iconified-button" @click="changeTheme">
<MoonIcon v-if="$theme.active === 'light'" class="icon" />
<SunIcon v-else class="icon" />
<span class="dropdown-item__text">
{{ formatMessage(messages.changeTheme) }}
</span>
</button>
</div>
</div>
<div class="mobile-navbar" :class="{ expanded: isBrowseMenuOpen || isMobileMenuOpen }">
<NuxtLink
to="/"
class="tab button-animation"
:title="formatMessage(navMenuMessages.home)"
:aria-label="formatMessage(navMenuMessages.home)"
>
<HomeIcon aria-hidden="true" />
</NuxtLink>
<button
class="tab button-animation"
:class="{ 'router-link-exact-active': isBrowseMenuOpen }"
:title="formatMessage(navMenuMessages.search)"
:aria-label="formatMessage(navMenuMessages.search)"
@click="toggleBrowseMenu()"
>
<template v-if="auth.user">
<SearchIcon aria-hidden="true" />
</template>
<template v-else>
<SearchIcon aria-hidden="true" class="smaller" />
{{ formatMessage(navMenuMessages.search) }}
</template>
</button>
<template v-if="auth.user">
<NuxtLink
to="/dashboard/notifications"
class="tab button-animation"
:aria-label="formatMessage(commonMessages.notificationsLabel)"
:class="{
'no-active': isMobileMenuOpen || isBrowseMenuOpen,
}"
:title="formatMessage(commonMessages.notificationsLabel)"
@click="
() => {
isMobileMenuOpen = false
isBrowseMenuOpen = false
}
"
>
<BellIcon aria-hidden="true" />
</NuxtLink>
<NuxtLink
to="/dashboard"
class="tab button-animation"
:aria-label="formatMessage(commonMessages.dashboardLabel)"
:title="formatMessage(commonMessages.dashboardLabel)"
>
<ChartIcon aria-hidden="true" />
</NuxtLink>
</template>
<button
class="tab button-animation"
:title="formatMessage(messages.toggleMenu)"
:aria-label="
isMobileMenuOpen ? formatMessage(messages.closeMenu) : formatMessage(messages.openMenu)
"
@click="toggleMobileMenu()"
>
<template v-if="!auth.user">
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
<XIcon v-else aria-hidden="true" />
</template>
<template v-else>
<Avatar
:src="auth.user.avatar_url"
class="user-icon"
:class="{ expanded: isMobileMenuOpen }"
:alt="formatMessage(messages.yourAvatarAlt)"
aria-hidden="true"
circle
/>
</template>
</button>
</div>
</header>
<main class="min-h-[calc(100vh-4.5rem-310.59px)]">
<ProjectCreateModal v-if="auth.user" ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" />
<OrganizationCreateModal ref="modal_organization_creation" />
<BatchCreditModal v-if="auth.user && isAdmin(auth.user)" ref="modal_batch_credit" />
<slot id="main" />
</main>
<ModrinthFooter />
</div>
</template>
<script setup>
import {
AffiliateIcon,
ArrowBigUpDashIcon,
BellIcon,
BoxIcon,
BracesIcon,
ChartIcon,
CollectionIcon,
CompassIcon,
CurrencyIcon,
DownloadIcon,
DropdownIcon,
FileIcon,
GlassesIcon,
HamburgerIcon,
HomeIcon,
IssuesIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
ModrinthIcon,
MoonIcon,
OrganizationIcon,
PackageOpenIcon,
PaintbrushIcon,
PlugIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldAlertIcon,
SunIcon,
UserIcon,
UserSearchIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
commonProjectTypeCategoryMessages,
defineMessages,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
import TextLogo from '~/components/brand/TextLogo.vue'
import BatchCreditModal from '~/components/ui/admin/BatchCreditModal.vue'
import GeneratedStateErrorsBanner from '~/components/ui/banner/GeneratedStateErrorsBanner.vue'
import RussiaBanner from '~/components/ui/banner/RussiaBanner.vue'
import StagingBanner from '~/components/ui/banner/StagingBanner.vue'
import SubscriptionPaymentFailedBanner from '~/components/ui/banner/SubscriptionPaymentFailedBanner.vue'
import TaxComplianceBanner from '~/components/ui/banner/TaxComplianceBanner.vue'
import TaxIdMismatchBanner from '~/components/ui/banner/TaxIdMismatchBanner.vue'
import VerifyEmailBanner from '~/components/ui/banner/VerifyEmailBanner.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
const country = useUserCountry()
const { formatMessage } = useVIntl()
const auth = await useAuth()
const user = await useUser()
const cosmetics = useCosmetics()
const flags = useFeatureFlags()
const config = useRuntimeConfig()
const route = useNativeRoute()
const router = useNativeRouter()
const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const { data: payoutBalance } = await useAsyncData('payout/balance', () =>
useBaseFetch('payout/balance', { apiVersion: 3 }),
)
const showTaxComplianceBanner = computed(() => {
if (flags.value.testTaxForm && auth.value.user) return true
const bal = payoutBalance.value
if (!bal) return false
const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600
const status = bal.form_completion_status ?? 'unknown'
const isComplete = status === 'complete'
const isTinMismatch = status === 'tin-mismatch'
return !!auth.value.user && thresholdMet && !isComplete && !isTinMismatch
})
const showTinMismatchBanner = computed(() => {
const bal = payoutBalance.value
if (!bal) return false
const status = bal.form_completion_status ?? 'unknown'
return !!auth.value.user && status === 'tin-mismatch'
})
const basePopoutId = useId()
const navMenuMessages = defineMessages({
home: {
id: 'layout.nav.home',
defaultMessage: 'Home',
},
search: {
id: 'layout.nav.search',
defaultMessage: 'Search',
},
discoverContent: {
id: 'layout.nav.discover-content',
defaultMessage: 'Discover content',
},
discover: {
id: 'layout.nav.discover',
defaultMessage: 'Discover',
},
hostAServer: {
id: 'layout.nav.host-a-server',
defaultMessage: 'Host a server',
},
getModrinthApp: {
id: 'layout.nav.get-modrinth-app',
defaultMessage: 'Get Modrinth App',
},
modrinthApp: {
id: 'layout.nav.modrinth-app',
defaultMessage: 'Modrinth App',
},
})
const messages = defineMessages({
toggleMenu: {
id: 'layout.menu-toggle.action',
defaultMessage: 'Toggle menu',
},
yourAvatarAlt: {
id: 'layout.avatar.alt',
defaultMessage: 'Your avatar',
},
changeTheme: {
id: 'layout.action.change-theme',
defaultMessage: 'Change theme',
},
modrinthHomePage: {
id: 'layout.nav.modrinth-home-page',
defaultMessage: 'Modrinth home page',
},
createNew: {
id: 'layout.action.create-new',
defaultMessage: 'Create new...',
},
reviewProjects: {
id: 'layout.action.review-projects',
defaultMessage: 'Project review',
},
techReview: {
id: 'layout.action.tech-review',
defaultMessage: 'Tech review',
},
reports: {
id: 'layout.action.reports',
defaultMessage: 'Review reports',
},
lookupByEmail: {
id: 'layout.action.lookup-by-email',
defaultMessage: 'Lookup by email',
},
fileLookup: {
id: 'layout.action.file-lookup',
defaultMessage: 'File lookup',
},
manageServerNotices: {
id: 'layout.action.manage-server-notices',
defaultMessage: 'Manage server notices',
},
manageAffiliates: {
id: 'layout.action.manage-affiliates',
defaultMessage: 'Manage affiliate links',
},
newProject: {
id: 'layout.action.new-project',
defaultMessage: 'New project',
},
newCollection: {
id: 'layout.action.new-collection',
defaultMessage: 'New collection',
},
newOrganization: {
id: 'layout.action.new-organization',
defaultMessage: 'New organization',
},
profile: {
id: 'layout.nav.profile',
defaultMessage: 'Profile',
},
savedProjects: {
id: 'layout.nav.saved-projects',
defaultMessage: 'Saved projects',
},
upgradeToModrinthPlus: {
id: 'layout.nav.upgrade-to-modrinth-plus',
defaultMessage: 'Upgrade to Modrinth+',
},
featureFlags: {
id: 'layout.nav.feature-flags',
defaultMessage: 'Feature flags',
},
projects: {
id: 'layout.nav.projects',
defaultMessage: 'Projects',
},
organizations: {
id: 'layout.nav.organizations',
defaultMessage: 'Organizations',
},
revenue: {
id: 'layout.nav.revenue',
defaultMessage: 'Revenue',
},
analytics: {
id: 'layout.nav.analytics',
defaultMessage: 'Analytics',
},
activeReports: {
id: 'layout.nav.active-reports',
defaultMessage: 'Active reports',
},
myServers: {
id: 'layout.nav.my-servers',
defaultMessage: 'My servers',
},
openMenu: {
id: 'layout.mobile.open-menu',
defaultMessage: 'Open menu',
},
closeMenu: {
id: 'layout.mobile.close-menu',
defaultMessage: 'Close menu',
},
})
useHead({
link: [
{
rel: 'canonical',
href: link,
},
],
})
useSeoMeta({
title: 'Modrinth',
description: () =>
formatMessage({
id: 'layout.meta.description',
defaultMessage:
'Download Minecraft mods, plugins, datapacks, shaders, resourcepacks, and modpacks on Modrinth. ' +
'Discover and publish projects on Modrinth with a modern, easy to use interface and API.',
}),
publisher: 'Modrinth',
themeColor: '#1bd96a',
colorScheme: 'dark light',
// OpenGraph
ogTitle: 'Modrinth',
ogSiteName: 'Modrinth',
ogDescription: () =>
formatMessage({
id: 'layout.meta.og-description',
defaultMessage: 'Discover and publish Minecraft content!',
}),
ogType: 'website',
ogImage: 'https://cdn.modrinth.com/modrinth-new.png',
ogUrl: link,
// Twitter
twitterCard: 'summary',
twitterSite: '@modrinth',
})
const isMobileMenuOpen = ref(false)
const isBrowseMenuOpen = ref(false)
const navRoutes = computed(() => [
{
id: 'mods',
label: formatMessage(getProjectTypeMessage('mod', true)),
href: '/discover/mods',
},
{
label: formatMessage(getProjectTypeMessage('plugin', true)),
href: '/discover/plugins',
},
{
label: formatMessage(getProjectTypeMessage('datapack', true)),
href: '/discover/datapacks',
},
{
label: formatMessage(getProjectTypeMessage('shader', true)),
href: '/discover/shaders',
},
{
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
href: '/discover/resourcepacks',
},
{
label: formatMessage(getProjectTypeMessage('modpack', true)),
href: '/discover/modpacks',
},
])
const userMenuOptions = computed(() => {
let options = [
{
id: 'profile',
link: `/user/${auth.value.user.username}`,
},
{
id: 'plus',
link: '/plus',
color: 'purple',
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(auth.value.user.badges, 1 << 0),
},
{
id: 'servers',
link: '/hosting/manage',
},
{
id: 'flags',
link: '/flags',
shown: flags.value.developerMode,
},
{
id: 'settings',
link: '/settings',
},
]
// TODO: Only show if user has projects
options = [
...options,
{
divider: true,
},
{
id: 'notifications',
link: '/dashboard/notifications',
},
{
id: 'reports',
link: '/dashboard/reports',
},
{
id: 'saved',
link: '/dashboard/collections',
},
{
divider: true,
},
{
id: 'projects',
link: '/dashboard/projects',
},
{
id: 'organizations',
link: '/dashboard/organizations',
},
{
id: 'analytics',
link: '/dashboard/analytics',
},
{
id: 'affiliate-links',
link: '/dashboard/affiliate-links',
shown: auth.value.user.badges & UserBadge.AFFILIATE,
},
{
id: 'revenue',
link: '/dashboard/revenue',
},
]
options = [
...options,
{
divider: true,
},
{
id: 'sign-out',
color: 'danger',
action: () => logoutUser(),
hoverFilled: true,
},
]
return options
})
const isDiscovering = computed(
() => route.name && route.name.startsWith('discover-') && !route.query.sid,
)
const isDiscoveringSubpage = computed(
() => route.name && route.name.startsWith('type-id') && !route.query.sid,
)
const isRussia = computed(() => country.value === 'ru')
const rCount = ref(0)
const randomProjects = ref([])
const disableRandomProjects = ref(false)
const disableRandomProjectsForRoute = computed(
() =>
route.name.startsWith('hosting') ||
route.name.includes('settings') ||
route.name.includes('admin'),
)
async function onKeyDown(event) {
if (disableRandomProjects.value || disableRandomProjectsForRoute.value) {
return
}
if (event.key === 'r') {
rCount.value++
if (randomProjects.value.length < 3) {
randomProjects.value = await useBaseFetch('projects_random?count=50').catch((err) => {
console.error(err)
return []
})
}
}
if (rCount.value >= 40) {
rCount.value = 0
const randomProject = randomProjects.value[0]
await router.push(`/project/${randomProject.slug}`)
randomProjects.value.splice(0, 1)
}
}
function onKeyUp(event) {
if (event.key === 'r') {
rCount.value = 0
}
}
onMounted(() => {
if (window && import.meta.client) {
window.history.scrollRestoration = 'auto'
}
runAnalytics()
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
})
watch(
() => route.path,
() => {
isMobileMenuOpen.value = false
isBrowseMenuOpen.value = false
if (import.meta.client) {
document.body.style.overflowY = 'scroll'
document.body.setAttribute('tabindex', '-1')
document.body.removeAttribute('tabindex')
}
runAnalytics()
},
)
async function logoutUser() {
await logout()
}
function runAnalytics() {
const config = useRuntimeConfig()
const replacedUrl = config.public.apiBaseUrl.replace('v2/', '')
try {
setTimeout(() => {
$fetch(`${replacedUrl}analytics/view`, {
method: 'POST',
body: {
url: window.location.href,
},
headers: {
Authorization: auth.value.token,
},
})
.then(() => {})
.catch(() => {})
})
} catch (e) {
console.error(`Sending analytics failed (CORS error? If so, ignore)`, e)
}
}
function toggleMobileMenu() {
isMobileMenuOpen.value = !isMobileMenuOpen.value
if (isMobileMenuOpen.value) {
isBrowseMenuOpen.value = false
}
}
function toggleBrowseMenu() {
isBrowseMenuOpen.value = !isBrowseMenuOpen.value
if (isBrowseMenuOpen.value) {
isMobileMenuOpen.value = false
}
}
const { cycle: changeTheme } = useTheme()
</script>
<style lang="scss">
@import '~/assets/styles/global.scss';
// @import '@modrinth/assets';
.layout {
min-height: 100vh;
display: block;
@media screen and (min-width: 1024px) {
min-height: calc(100vh - var(--spacing-card-bg));
}
main {
grid-area: main;
}
}
@media (min-width: 1024px) {
.layout {
main {
.alpha-alert {
margin: 1rem;
.wrapper {
padding: 1rem 2rem 1rem 1rem;
}
}
}
}
}
@media (max-width: 1200px) {
.app-btn {
display: none;
}
}
.mobile-navigation {
display: none;
.nav-menu {
width: 100%;
position: fixed;
bottom: calc(var(--size-mobile-navbar-height) - var(--size-rounded-card));
padding-bottom: var(--size-rounded-card);
left: 0;
background-color: var(--color-raised-bg);
z-index: 11; // 20 = modals, 10 = svg icons
transform: translateY(100%);
transition: transform 0.4s cubic-bezier(0.54, 0.84, 0.42, 1);
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0);
.links,
.account-container {
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-gap: 1rem;
justify-content: center;
padding: 1rem;
.iconified-button {
width: 100%;
max-width: 500px;
padding: 0.75rem;
justify-content: center;
font-weight: 600;
font-size: 1rem;
margin: 0 auto;
}
}
.cascade-links {
@media screen and (min-width: 354px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 674px) {
grid-template-columns: repeat(3, 1fr);
}
}
&-browse {
&.expanded {
transform: translateY(0);
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.3);
}
}
&-mobile {
.account-container {
padding-bottom: 0;
.account-button {
padding: var(--spacing-card-md);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
.user-icon {
width: 2.25rem;
height: 2.25rem;
}
.account-text {
flex-grow: 0;
}
}
}
&.expanded {
transform: translateY(0);
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.3);
}
}
}
.mobile-navbar {
display: flex;
height: calc(var(--size-mobile-navbar-height) + env(safe-area-inset-bottom));
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
padding-bottom: env(safe-area-inset-bottom);
position: fixed;
left: 0;
bottom: 0;
background-color: var(--color-raised-bg);
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.3);
z-index: 11; // 20 = modals, 10 = svg icons
width: 100%;
align-items: center;
justify-content: space-between;
transition: border-radius 0.3s ease-out;
border-top: 2px solid rgba(0, 0, 0, 0);
box-sizing: border-box;
&.expanded {
box-shadow: none;
border-radius: 0;
}
.tab {
position: relative;
background: none;
display: flex;
flex-basis: 0;
justify-content: center;
align-items: center;
flex-direction: row;
gap: 0.25rem;
font-weight: bold;
padding: 0;
transition: color ease-in-out 0.15s;
color: var(--color-text-inactive);
text-align: center;
&.browse {
svg {
transform: rotate(180deg);
transition: transform ease-in-out 0.3s;
&.closed {
transform: rotate(0deg);
}
}
}
&.bubble {
&::after {
background-color: var(--color-brand);
border-radius: var(--size-rounded-max);
content: '';
height: 0.5rem;
position: absolute;
left: 1.5rem;
top: 0;
width: 0.5rem;
}
}
svg {
height: 1.75rem;
width: 1.75rem;
&.smaller {
width: 1.25rem;
height: 1.25rem;
}
}
.user-icon {
width: 2rem;
height: 2rem;
transition: border ease-in-out 0.15s;
border: 0 solid var(--color-brand);
box-sizing: border-box;
&.expanded {
border: 2px solid var(--color-brand);
}
}
&:hover,
&:focus {
color: var(--color-text);
}
&:first-child {
margin-left: 2rem;
}
&:last-child {
margin-right: 2rem;
}
&.router-link-exact-active:not(&.no-active) {
svg {
color: var(--color-brand);
}
color: var(--color-brand);
}
}
}
}
@media (any-hover: none) and (max-width: 640px) {
.desktop-only {
display: none;
}
}
@media (any-hover: none) and (max-width: 640px) {
.mobile-navigation {
display: flex;
}
}
.over-the-top-random-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 0;
animation:
tilt-shaking calc(0.2s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
translate-x-shaking calc(0.3s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
translate-y-shaking calc(0.25s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite;
&.threshold {
opacity: 1;
}
&.rings-expand {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: calc(1 + max((var(--_r-count) - 20), 0) * 0.1);
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
@keyframes tilt-shaking {
0% {
rotate: 0deg;
}
25% {
rotate: calc(1deg * (var(--_r-count) - 20));
}
50% {
rotate: 0deg;
}
75% {
rotate: calc(-1deg * (var(--_r-count) - 20));
}
100% {
rotate: 0deg;
}
}
@keyframes translate-x-shaking {
0% {
translate: 0;
}
25% {
translate: calc(2px * (var(--_r-count) - 20));
}
50% {
translate: 0;
}
75% {
translate: calc(-2px * (var(--_r-count) - 20));
}
100% {
translate: 0;
}
}
@keyframes translate-y-shaking {
0% {
transform: translateY(0);
}
25% {
transform: translateY(calc(2px * (var(--_r-count) - 20)));
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(calc(-2px * (var(--_r-count) - 20)));
}
100% {
transform: translateY(0);
}
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>