1
0

NormalPage component w/ Collections refactor (#4873)

* Refactor search page, migrate to /discover/

* Add NormalPage component for common layouts, refactor Collections page as an example, misc ui pkg cleanup

* intl:extract

* lint

* lint

* remove old components

* Refactor search page, migrate to /discover/

* Add NormalPage component for common layouts, refactor Collections page as an example, misc ui pkg cleanup

* intl:extract

* lint

* lint

* remove old components
This commit is contained in:
Prospector
2025-12-09 14:44:10 -08:00
committed by GitHub
parent 1d64b2e22a
commit 0a8f489234
67 changed files with 1201 additions and 1771 deletions

View File

@@ -37,6 +37,7 @@ import {
ProgressSpinner,
provideModrinthClient,
provideNotificationManager,
providePageContext,
useDebugLogger,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
@@ -119,7 +120,10 @@ const tauriApiClient = new TauriModrinthClient({
],
})
provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
})
const news = ref([])
const availableSurvey = ref(false)

View File

@@ -2,6 +2,8 @@
@tailwind components;
@tailwind utilities;
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;

View File

@@ -6,7 +6,12 @@
</NuxtLayout>
</template>
<script setup lang="ts">
import { NotificationPanel, provideModrinthClient, provideNotificationManager } from '@modrinth/ui'
import {
NotificationPanel,
provideModrinthClient,
provideNotificationManager,
providePageContext,
} from '@modrinth/ui'
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import { createModrinthClient } from '~/helpers/api.ts'
@@ -23,4 +28,8 @@ const client = createModrinthClient(auth, {
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
})
</script>

View File

@@ -135,21 +135,6 @@
'sidebar'
/ 100%;
.normal-page__ultimate-sidebar {
grid-area: ultimate-sidebar;
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 100;
max-width: calc(100% - 2rem);
max-height: calc(100vh - 2rem);
overflow-y: auto;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
@media screen and (min-width: 1024px) {
&.sidebar {
grid-template:
@@ -173,45 +158,6 @@
}
}
@media screen and (min-width: 1400px) {
&.ultimate-sidebar {
max-width: calc(80rem + 0.75rem + 600px);
grid-template:
'header header ultimate-sidebar' auto
'content sidebar ultimate-sidebar' auto
'content dummy ultimate-sidebar' 1fr
/ 1fr 18.75rem auto;
.normal-page__header {
max-width: 80rem;
}
.normal-page__ultimate-sidebar {
position: sticky;
top: 4.5rem;
bottom: unset;
right: unset;
z-index: unset;
align-self: start;
display: flex;
height: calc(100vh - 4.5rem * 2);
> div {
box-shadow: none;
}
}
&.alt-layout {
grid-template:
'ultimate-sidebar header header' auto
'ultimate-sidebar sidebar content' auto
'ultimate-sidebar dummy content' 1fr
/ auto 18.75rem 1fr;
}
}
}
.normal-page__sidebar {
grid-area: sidebar;
}

View File

@@ -1,3 +1,5 @@
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,160 +0,0 @@
<template>
<nav class="navigation">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="rowLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
<span>{{ link.label }}</span>
</NuxtLink>
<div
class="nav-indicator"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup>
const route = useNativeRoute()
const props = defineProps({
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
})
const sliderPositionX = ref(0)
const sliderPositionY = ref(18)
const selectedElementWidth = ref(0)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const positionToMoveX = computed(() => `${sliderPositionX.value}px`)
const positionToMoveY = computed(() => `${sliderPositionY.value}px`)
const sliderWidth = computed(() => `${selectedElementWidth.value}px`)
function pickLink() {
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === '' ? undefined : x.href) === route.path[props.query],
)
: filteredLinks.value.findIndex((x) => x.href === decodeURIComponent(route.path))
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderPositionX.value = 0
selectedElementWidth.value = 0
}
}
const rowLinkElements = ref()
function startAnimation() {
const el = rowLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
sliderPositionX.value = el.offsetLeft
sliderPositionY.value = el.offsetTop + el.offsetHeight
selectedElementWidth.value = el.offsetWidth
}
onMounted(() => {
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => pickLink())
</script>
<style lang="scss" scoped>
.navigation {
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 1rem;
flex-wrap: wrap;
position: relative;
.nav-link {
text-transform: capitalize;
font-weight: var(--font-weight-bold);
color: var(--color-text);
position: relative;
&:hover {
color: var(--color-text);
&::after {
opacity: 0.4;
}
}
&:active::after {
opacity: 0.2;
}
&.router-link-exact-active {
color: var(--color-text);
&:not(:focus-visible) {
outline: 2px solid transparent;
outline-offset: 6px;
border-radius: 0.25rem;
}
&::after {
opacity: 1;
}
}
}
&.use-animation {
.nav-link {
&.is-active::after {
opacity: 0;
}
}
}
.nav-indicator {
position: absolute;
height: 0.25rem;
bottom: -5px;
left: 0;
width: 3rem;
transition: all ease-in-out 0.2s;
border-radius: var(--size-rounded-max);
background-color: var(--color-brand);
outline: 2px solid transparent;
outline-offset: -2px;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
}
</style>

View File

@@ -158,12 +158,18 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Chips, injectNotificationManager, NewModal } from '@modrinth/ui'
import {
Admonition,
ButtonStyled,
Chips,
injectNotificationManager,
NewModal,
normalizeChildren,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
import { normalizeChildren } from '@/utils/vue-children.ts'
const props = withDefaults(
defineProps<{

View File

@@ -124,6 +124,7 @@
</template>
<script setup lang="ts">
import { normalizeChildren } from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
@@ -133,7 +134,6 @@ import ConfettiExplosion from 'vue-confetti-explosion'
import { type TremendousProviderData, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { getRailConfig } from '@/utils/muralpay-rails'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData } = useWithdrawContext()
const { formatMessage } = useVIntl()

View File

@@ -104,7 +104,13 @@
<script setup lang="ts">
import { CheckIcon, PayPalColorIcon, SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Checkbox, financialMessages, formFieldLabels } from '@modrinth/ui'
import {
ButtonStyled,
Checkbox,
financialMessages,
formFieldLabels,
normalizeChildren,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { useDebounceFn } from '@vueuse/core'
@@ -114,7 +120,6 @@ import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
useWithdrawContext()

View File

@@ -84,6 +84,7 @@ import {
ButtonStyled,
Combobox,
injectNotificationManager,
normalizeChildren,
useDebugLogger,
} from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
@@ -93,7 +94,6 @@ import { useGeolocation } from '@vueuse/core'
import { useCountries, useFormattedCountries, useUserCountry } from '@/composables/country.ts'
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const debug = useDebugLogger('MethodSelectionStage')
const {

View File

@@ -207,6 +207,7 @@ import {
financialMessages,
formFieldLabels,
formFieldPlaceholders,
normalizeChildren,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
@@ -224,7 +225,6 @@ import {
getCurrencyIcon,
} from '@/utils/finance-icons.ts'
import { getRailConfig } from '@/utils/muralpay-rails'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees } = useWithdrawContext()
const { formatMessage } = useVIntl()

View File

@@ -74,14 +74,13 @@
<script setup lang="ts">
import { FileTextIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled } from '@modrinth/ui'
import { Admonition, ButtonStyled, normalizeChildren } from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { computed } from 'vue'
import { TAX_THRESHOLD_ACTUAL } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const props = defineProps<{
balance: any

View File

@@ -181,6 +181,7 @@ import {
financialMessages,
formFieldLabels,
formFieldPlaceholders,
normalizeChildren,
paymentMethodMessages,
useDebugLogger,
} from '@modrinth/ui'
@@ -195,7 +196,6 @@ import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown
import { useAuth } from '@/composables/auth.js'
import { useBaseFetch } from '@/composables/fetch.js'
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const debug = useDebugLogger('TremendousDetailsStage')
const {

View File

@@ -52,7 +52,12 @@
<script setup>
import { SadRinthbot } from '@modrinth/assets'
import { NotificationPanel, provideModrinthClient, provideNotificationManager } from '@modrinth/ui'
import {
NotificationPanel,
provideModrinthClient,
provideNotificationManager,
providePageContext,
} from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
@@ -73,6 +78,10 @@ const client = createModrinthClient(auth.value, {
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
})
const { formatMessage } = useVIntl()

View File

@@ -365,20 +365,26 @@
"auth.welcome.title": {
"message": "Welcome"
},
"collection.button.delete-icon": {
"message": "Delete icon"
},
"collection.button.edit-icon": {
"message": "Edit icon"
},
"collection.button.remove-icon": {
"message": "Remove icon"
},
"collection.button.remove-project": {
"message": "Remove project"
},
"collection.button.replace-icon": {
"message": "Replace icon"
},
"collection.button.select-icon": {
"message": "Select icon"
},
"collection.button.unfollow-project": {
"message": "Unfollow project"
},
"collection.delete-modal.description": {
"message": "This will remove this collection forever. This action cannot be undone."
"message": "This will permanently delete this collection. This action cannot be undone."
},
"collection.delete-modal.title": {
"message": "Are you sure you want to delete this collection?"
@@ -389,33 +395,39 @@
"collection.description.following": {
"message": "Auto-generated collection of all the projects you're following."
},
"collection.editing": {
"message": "Editing collection"
},
"collection.error.not-found": {
"message": "Collection not found"
},
"collection.label.collection": {
"message": "Collection"
},
"collection.label.created-at": {
"message": "Created {ago}"
},
"collection.label.curated-by": {
"message": "Curated by"
},
"collection.label.description": {
"message": "Description"
},
"collection.label.details": {
"message": "Details"
},
"collection.label.no-projects": {
"message": "This collection has no projects!"
},
"collection.label.no-projects-auth": {
"message": "You don't have any projects.\nWould you like to <create-link>add one</create-link>?"
},
"collection.label.owner": {
"message": "Owner"
"message": "No projects in collection yet"
},
"collection.label.projects-count": {
"message": "{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}"
"message": "{count, plural, =0 {No projects yet} one {<stat>{count}</stat> project} other {<stat>{count}</stat> {type}}}"
},
"collection.label.updated-at": {
"message": "Updated {ago}"
},
"collection.return-link.dashboard-collections": {
"message": "Your collections"
},
"collection.return-link.user": {
"message": "{user}'s profile"
},
"collection.title": {
"message": "{name} - Collection"
},

View File

@@ -81,13 +81,18 @@
<script setup>
import { CheckIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, commonMessages, injectNotificationManager } from '@modrinth/ui'
import {
Avatar,
Button,
commonMessages,
injectNotificationManager,
normalizeChildren,
} from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import { useAuth } from '@/composables/auth.js'
import { useScopes } from '@/composables/auth/scopes.ts'
import { useBaseFetch } from '@/composables/fetch.js'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()

View File

@@ -49,11 +49,9 @@
<script setup>
import { RightArrowIcon, WavingRinthbot } from '@modrinth/assets'
import { Checkbox, commonMessages } from '@modrinth/ui'
import { Checkbox, commonMessages, normalizeChildren } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import { normalizeChildren } from '@/utils/vue-children.ts'
const route = useRoute()
const { formatMessage } = useVIntl()

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@
:to="`/collection/following`"
class="universal-card recessed collection"
>
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
<Avatar src="https://cdn.modrinth.com/follow-collection.png" size="64px" />
<div class="details">
<span class="title">{{ formatMessage(commonMessages.followedProjectsLabel) }}</span>
<span class="description">
@@ -57,7 +57,7 @@
:to="`/collection/${collection.id}`"
class="universal-card recessed collection"
>
<Avatar :src="collection.icon_url" class="icon" />
<Avatar :src="collection.icon_url" size="64px" />
<div class="details">
<span class="title">{{ collection.name }}</span>
<span class="description">
@@ -188,15 +188,6 @@ const orderedCollections = computed(() => {
gap: var(--gap-md);
margin-bottom: 0;
.icon {
width: 100% !important;
height: 6rem !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;

View File

@@ -205,12 +205,11 @@
<script setup lang="ts">
import { CodeIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import { Button, injectNotificationManager, ThemeSelector } from '@modrinth/ui'
import { Button, injectNotificationManager, normalizeChildren, ThemeSelector } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { normalizeChildren } from '@/utils/vue-children.ts'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import type { DisplayLocation } from '~/plugins/cosmetics'
import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts'

View File

@@ -276,7 +276,7 @@
</ContentPageHeader>
</div>
<div class="normal-page__content">
<div v-if="navLinks.length >= 2" class="mb-4 max-w-full overflow-x-auto">
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
<NavTabs :links="navLinks" />
</div>
<div v-if="projects.length > 0">
@@ -352,7 +352,7 @@
class="card collection-item"
>
<div class="collection">
<Avatar :src="collection.icon_url" class="icon" />
<Avatar :src="collection.icon_url" size="64px" />
<div class="details">
<h2 class="title">{{ collection.name }}</h2>
<div class="stats">

View File

@@ -7,6 +7,7 @@ import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
import { LabrinthProjectsV3Module } from './labrinth/projects/v3'
import { LabrinthStateModule } from './labrinth/state'
@@ -30,6 +31,7 @@ export const MODULE_REGISTRY = {
iso3166_data: ISO3166Module,
kyros_files_v0: KyrosFilesV0Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_projects_v2: LabrinthProjectsV2Module,
labrinth_projects_v3: LabrinthProjectsV3Module,
labrinth_state: LabrinthStateModule,

View File

@@ -0,0 +1,128 @@
import { AbstractModule } from '../../core/abstract-module.js'
import type { Labrinth } from '../types'
export class LabrinthCollectionsModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_collections'
}
/**
* Get a collection by ID (v3)
*
* @param id - Collection ID
* @returns Promise resolving to the collection data
*
* @example
* ```typescript
* const collection = await client.labrinth.collections.get('AANobbMI')
* ```
*/
public async get(id: string): Promise<Labrinth.Collections.Collection> {
return this.client.request<Labrinth.Collections.Collection>(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get multiple collections by IDs (v3)
*
* @param ids - Array of collection IDs
* @returns Promise resolving to array of collections
*
* @example
* ```typescript
* const collections = await client.labrinth.collections.getMultiple(['AANobbMI', 'BBNoobMI'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Collections.Collection[]> {
return this.client.request<Labrinth.Collections.Collection[]>(`/collections`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Edit a collection (v3)
*
* @param id - Collection ID
* @param data - Collection update data
*
* @example
* ```typescript
* await client.labrinth.collections.edit('AANobbMI', {
* name: 'Updated name',
* description: 'Updated description',
* status: 'listed'
* })
* ```
*/
public async edit(id: string, data: Labrinth.Collections.EditCollectionRequest): Promise<void> {
return this.client.request(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete a collection (v3)
*
* @param id - Collection ID
*
* @example
* ```typescript
* await client.labrinth.collections.delete('AANobbMI')
* ```
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
/**
* Edit a collection icon (v3)
*
* @param id - Collection ID
* @param icon - Icon file
* @param ext - File extension (e.g., 'png', 'jpg')
*
* @example
* ```typescript
* await client.labrinth.collections.editIcon('AANobbMI', iconFile, 'png')
* ```
*/
public async editIcon(id: string, icon: Blob, ext: string): Promise<void> {
return this.client.request(`/collection/${id}/icon?ext=${ext}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: icon,
})
}
/**
* Delete a collection icon (v3)
*
* @param id - Collection ID
*
* @example
* ```typescript
* await client.labrinth.collections.deleteIcon('AANobbMI')
* ```
*/
public async deleteIcon(id: string): Promise<void> {
return this.client.request(`/collection/${id}/icon`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

View File

@@ -1,4 +1,5 @@
export * from './billing/internal'
export * from './collections'
export * from './projects/v2'
export * from './projects/v3'
export * from './state'

View File

@@ -477,6 +477,77 @@ export namespace Labrinth {
}
}
export namespace Users {
namespace Common {
export type Role = 'developer' | 'moderator' | 'admin'
export type AuthProvider =
| 'github'
| 'discord'
| 'microsoft'
| 'gitlab'
| 'google'
| 'steam'
| 'paypal'
export type UserPayoutData = {
paypal_address?: string
paypal_country?: string
venmo_handle?: string
balance: number
}
}
export namespace v2 {
export type Role = Common.Role
export type AuthProvider = Common.AuthProvider
export type UserPayoutData = Common.UserPayoutData
export type User = {
id: string
username: string
name?: string
avatar_url?: string
bio?: string
created: string
role: Role
badges: number
auth_providers?: AuthProvider[]
email?: string
email_verified?: boolean
has_password?: boolean
has_totp?: boolean
payout_data?: UserPayoutData
github_id?: number
}
}
export namespace v3 {
export type Role = Common.Role
export type AuthProvider = Common.AuthProvider
export type UserPayoutData = Common.UserPayoutData
export type User = {
id: string
username: string
avatar_url?: string
bio?: string
created: string
role: Role
badges: number
auth_providers?: AuthProvider[]
email?: string
email_verified?: boolean
has_password?: boolean
has_totp?: boolean
payout_data?: UserPayoutData
stripe_customer_id?: string
allow_friend_requests?: boolean
github_id?: number
}
}
}
export namespace Tags {
export namespace v2 {
export interface Category {
@@ -541,6 +612,30 @@ export namespace Labrinth {
}
}
export namespace Collections {
export type CollectionStatus = 'listed' | 'unlisted' | 'private' | 'rejected' | 'unknown'
export type Collection = {
id: string
user: string
name: string
description: string | null
icon_url: string | null
color: number | null
status: CollectionStatus
created: string
updated: string
projects: string[]
}
export type EditCollectionRequest = {
name?: string
description?: string | null
status?: CollectionStatus
new_projects?: string[]
}
}
export namespace State {
export interface GeneratedState {
categories: Tags.v2.Category[]

View File

@@ -93,6 +93,7 @@ import _Heading2Icon from './icons/heading-2.svg?component'
import _Heading3Icon from './icons/heading-3.svg?component'
import _HeartIcon from './icons/heart.svg?component'
import _HeartHandshakeIcon from './icons/heart-handshake.svg?component'
import _HeartMinusIcon from './icons/heart-minus.svg?component'
import _HistoryIcon from './icons/history.svg?component'
import _HomeIcon from './icons/home.svg?component'
import _ImageIcon from './icons/image.svg?component'
@@ -308,6 +309,7 @@ export const Heading1Icon = _Heading1Icon
export const Heading2Icon = _Heading2Icon
export const Heading3Icon = _Heading3Icon
export const HeartHandshakeIcon = _HeartHandshakeIcon
export const HeartMinusIcon = _HeartMinusIcon
export const HeartIcon = _HeartIcon
export const HistoryIcon = _HistoryIcon
export const HomeIcon = _HomeIcon

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heart-minus-icon lucide-heart-minus"><path d="m14.876 18.99-1.368 1.323a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5a5.2 5.2 0 0 1-.244 1.572"/><path d="M15 15h6"/></svg>

After

Width:  |  Height:  |  Size: 438 B

View File

@@ -0,0 +1,47 @@
<svg viewBox="0 50 250 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M63 134H154C154.515 134 155.017 133.944 155.5 133.839C155.983 133.944 156.485 134 157 134H209C212.866 134 216 130.866 216 127C216 123.134 212.866 120 209 120H203C199.134 120 196 116.866 196 113C196 109.134 199.134 106 203 106H222C225.866 106 229 102.866 229 99C229 95.134 225.866 92 222 92H200C203.866 92 207 88.866 207 85C207 81.134 203.866 78 200 78H136C139.866 78 143 74.866 143 71C143 67.134 139.866 64 136 64H79C75.134 64 72 67.134 72 71C72 74.866 75.134 78 79 78H39C35.134 78 32 81.134 32 85C32 88.866 35.134 92 39 92H64C67.866 92 71 95.134 71 99C71 102.866 67.866 106 64 106H24C20.134 106 17 109.134 17 113C17 116.866 20.134 120 24 120H63C59.134 120 56 123.134 56 127C56 130.866 59.134 134 63 134ZM226 134C229.866 134 233 130.866 233 127C233 123.134 229.866 120 226 120C222.134 120 219 123.134 219 127C219 130.866 222.134 134 226 134Z"
fill="var(--surface-2, #1D1F23)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M113.119 112.307C113.04 112.86 113 113.425 113 114C113 120.627 118.373 126 125 126C131.627 126 137 120.627 137 114C137 113.425 136.96 112.86 136.881 112.307H166V139C166 140.657 164.657 142 163 142H87C85.3431 142 84 140.657 84 139V112.307H113.119Z"
fill="var(--surface-1, #16181C)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M138 112C138 119.18 132.18 125 125 125C117.82 125 112 119.18 112 112C112 111.767 112.006 111.536 112.018 111.307H84L93.5604 83.0389C93.9726 81.8202 95.1159 81 96.4023 81H153.598C154.884 81 156.027 81.8202 156.44 83.0389L166 111.307H137.982C137.994 111.536 138 111.767 138 112Z"
fill="var(--surface-1, #16181C)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M136.098 112.955C136.098 118.502 131.129 124 125 124C118.871 124 113.902 118.502 113.902 112.955C113.902 112.775 113.908 111.596 113.918 111.419H93L101.161 91.5755C101.513 90.6338 102.489 90 103.587 90H146.413C147.511 90 148.487 90.6338 148.839 91.5755L157 111.419H136.082C136.092 111.596 136.098 112.775 136.098 112.955Z"
fill="var(--surface-2, #1D1F23)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M85.25 111.512V138C85.25 138.966 86.0335 139.75 87 139.75H163C163.966 139.75 164.75 138.966 164.75 138V111.512L155.255 83.4393C155.015 82.7285 154.348 82.25 153.598 82.25H96.4023C95.6519 82.25 94.985 82.7285 94.7446 83.4393L85.25 111.512Z"
stroke="var(--surface-4, #34363C)"
stroke-width="2.5"
/>
<path
d="M98 111C101.937 111 106.185 111 110.745 111C112.621 111 112.621 112.319 112.621 113C112.621 119.627 118.117 125 124.897 125C131.677 125 137.173 119.627 137.173 113C137.173 112.319 137.173 111 139.05 111H164M90.5737 111H93H90.5737Z"
stroke="var(--surface-4, #34363C)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M150.1 58.3027L139 70.7559M124.1 54V70.7559V54ZM98 58.3027L109.1 70.7559L98 58.3027Z"
stroke="var(--surface-3, #27292E)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -62,6 +62,7 @@ import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component'
import _YouTubeGaming from './external/youtubegaming.svg?component'
import _YouTubeShortsIcon from './external/youtubeshorts.svg?component'
import _EmptyIllustration from './illustrations/empty.svg?component'
export const ModrinthIcon = _ModrinthIcon
export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration
@@ -119,3 +120,5 @@ export const MinecraftServerIcon = _MinecraftServerIcon
export * from './generated-icons'
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
export { default as SlimPlayerModel } from './models/slim-player.gltf?url'
export const EmptyIllustration = _EmptyIllustration

View File

@@ -0,0 +1,2 @@
export { default as AffiliateLinkCard } from './AffiliateLinkCard.vue'
export { default as AffiliateLinkCreateModal } from './AffiliateLinkCreateModal.vue'

View File

@@ -0,0 +1,3 @@
<template>
<div class="h-[1px] w-full bg-divider"></div>
</template>

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex flex-wrap gap-2">
<div class="flex flex-col gap-1">
<button
v-for="(item, index) in items"
:key="`radio-button-${index}`"
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
class="p-0 py-2 px-2 w-fit border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
:class="{
'text-contrast bg-button-bg': selected === item,
'text-primary bg-transparent': selected !== item,

View File

@@ -1,27 +0,0 @@
<template>
<div class="flex items-center gap-3">
<slot></slot>
<div class="flex flex-col">
<span class="font-bold">{{ value }}</span>
<span class="text-secondary">{{ label }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
})
</script>
<style scoped>
:slotted(*) {
@apply h-6 w-6 text-secondary;
}
</style>

View File

@@ -0,0 +1,56 @@
export { default as Accordion } from './Accordion.vue'
export { default as Admonition } from './Admonition.vue'
export { default as AppearingProgressBar } from './AppearingProgressBar.vue'
export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
export { default as AutoLink } from './AutoLink.vue'
export { default as Avatar } from './Avatar.vue'
export { default as Badge } from './Badge.vue'
export { default as BulletDivider } from './BulletDivider.vue'
export { default as Button } from './Button.vue'
export { default as ButtonStyled } from './ButtonStyled.vue'
export { default as Card } from './Card.vue'
export { default as Checkbox } from './Checkbox.vue'
export { default as Chips } from './Chips.vue'
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export { default as Combobox } from './Combobox.vue'
export { default as ContentPageHeader } from './ContentPageHeader.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DoubleIcon } from './DoubleIcon.vue'
export { default as DropArea } from './DropArea.vue'
export { default as DropdownSelect } from './DropdownSelect.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export { default as HeadingLink } from './HeadingLink.vue'
export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as IconSelect } from './IconSelect.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'
export { default as JoinedButtons } from './JoinedButtons.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'
export { default as Page } from './Page.vue'
export { default as Pagination } from './Pagination.vue'
export { default as PopoutMenu } from './PopoutMenu.vue'
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
export { default as ProgressBar } from './ProgressBar.vue'
export { default as ProgressSpinner } from './ProgressSpinner.vue'
export { default as ProjectCard } from './ProjectCard.vue'
export { default as RadialHeader } from './RadialHeader.vue'
export { default as RadioButtons } from './RadioButtons.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
export { default as TagItem } from './TagItem.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'

View File

@@ -0,0 +1,5 @@
export { default as AddPaymentMethodModal } from './AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue'
export { default as PurchaseModal } from './PurchaseModal.vue'
export { default as ServersSpecs } from './ServersSpecs.vue'
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'

View File

@@ -0,0 +1,2 @@
export { default as AnimatedLogo } from './AnimatedLogo.vue'
export { default as TextLogo } from './TextLogo.vue'

View File

@@ -0,0 +1 @@
export { default as ChangelogEntry } from './ChangelogEntry.vue'

View File

@@ -0,0 +1,2 @@
export { default as Chart } from './Chart.vue'
export { default as CompactChart } from './CompactChart.vue'

View File

@@ -0,0 +1,3 @@
export { default as ContentListPanel } from './ContentListPanel.vue'
export type { Article as NewsArticle } from './NewsArticleCard.vue'
export { default as NewsArticleCard } from './NewsArticleCard.vue'

View File

@@ -1,143 +1,16 @@
// Base content
export { default as Accordion } from './base/Accordion.vue'
export { default as Admonition } from './base/Admonition.vue'
export { default as AppearingProgressBar } from './base/AppearingProgressBar.vue'
export { default as AutoBrandIcon } from './base/AutoBrandIcon.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
export { default as BulletDivider } from './base/BulletDivider.vue'
export { default as Button } from './base/Button.vue'
export { default as ButtonStyled } from './base/ButtonStyled.vue'
export { default as Card } from './base/Card.vue'
export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
export { default as Combobox } from './base/Combobox.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue'
export { default as DropArea } from './base/DropArea.vue'
export { default as DropdownSelect } from './base/DropdownSelect.vue'
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './base/ErrorInformationCard.vue'
export { default as FileInput } from './base/FileInput.vue'
export type { FilterBarOption } from './base/FilterBar.vue'
export { default as FilterBar } from './base/FilterBar.vue'
export { default as HeadingLink } from './base/HeadingLink.vue'
export { default as IconSelect } from './base/IconSelect.vue'
export type { JoinedButtonAction } from './base/JoinedButtons.vue'
export { default as JoinedButtons } from './base/JoinedButtons.vue'
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
export { default as ManySelect } from './base/ManySelect.vue'
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
export { default as OptionGroup } from './base/OptionGroup.vue'
export type { Option as OverflowMenuOption } from './base/OverflowMenu.vue'
export { default as OverflowMenu } from './base/OverflowMenu.vue'
export { default as Page } from './base/Page.vue'
export { default as Pagination } from './base/Pagination.vue'
export { default as PopoutMenu } from './base/PopoutMenu.vue'
export { default as PreviewSelectButton } from './base/PreviewSelectButton.vue'
export { default as ProgressBar } from './base/ProgressBar.vue'
export { default as ProgressSpinner } from './base/ProgressSpinner.vue'
export { default as ProjectCard } from './base/ProjectCard.vue'
export { default as RadialHeader } from './base/RadialHeader.vue'
export { default as RadioButtons } from './base/RadioButtons.vue'
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as ServerNotice } from './base/ServerNotice.vue'
export { default as SettingsLabel } from './base/SettingsLabel.vue'
export { default as SimpleBadge } from './base/SimpleBadge.vue'
export { default as Slider } from './base/Slider.vue'
export { default as SmartClickable } from './base/SmartClickable.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as TagItem } from './base/TagItem.vue'
export { default as Timeline } from './base/Timeline.vue'
export { default as Toggle } from './base/Toggle.vue'
export { default as UnsavedChangesPopup } from './base/UnsavedChangesPopup.vue'
// Branding
export { default as AnimatedLogo } from './brand/AnimatedLogo.vue'
export { default as TextLogo } from './brand/TextLogo.vue'
// Changelog
export { default as ChangelogEntry } from './changelog/ChangelogEntry.vue'
// Charts
export { default as Chart } from './chart/Chart.vue'
export { default as CompactChart } from './chart/CompactChart.vue'
// Content
export { default as ContentListPanel } from './content/ContentListPanel.vue'
export type { Article as NewsArticle } from './content/NewsArticleCard.vue'
export { default as NewsArticleCard } from './content/NewsArticleCard.vue'
// Modals
export { default as ConfirmModal } from './modal/ConfirmModal.vue'
export { default as Modal } from './modal/Modal.vue'
export { default as NewModal } from './modal/NewModal.vue'
export { default as ShareModal } from './modal/ShareModal.vue'
export type { Tab as TabbedModalTab } from './modal/TabbedModal.vue'
export { default as TabbedModal } from './modal/TabbedModal.vue'
// Navigation
export { default as Breadcrumbs } from './nav/Breadcrumbs.vue'
export { default as NavItem } from './nav/NavItem.vue'
export { default as NavRow } from './nav/NavRow.vue'
export { default as NavStack } from './nav/NavStack.vue'
export { default as NotificationPanel } from './nav/NotificationPanel.vue'
export { default as PagewideBanner } from './nav/PagewideBanner.vue'
// Project
export * from './affiliate'
export * from './base'
export * from './billing'
export * from './brand'
export * from './changelog'
export * from './chart'
export * from './content'
export * from './modal'
export * from './nav'
export * from './page'
export * from './project'
// Search
export { default as BrowseFiltersPanel } from './search/BrowseFiltersPanel.vue'
export { default as Categories } from './search/Categories.vue'
export { default as SearchDropdown } from './search/SearchDropdown.vue'
export { default as SearchFilter } from './search/SearchFilter.vue'
export { default as SearchFilterControl } from './search/SearchFilterControl.vue'
export { default as SearchFilterOption } from './search/SearchFilterOption.vue'
export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue'
// Affiliate
export { default as AffiliateLinkCard } from './affiliate/AffiliateLinkCard.vue'
export { default as AffiliateLinkCreateModal } from './affiliate/AffiliateLinkCreateModal.vue'
// Billing
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
export { default as ServersUpgradeModalWrapper } from './billing/ServersUpgradeModalWrapper.vue'
// Skins
export { default as CapeButton } from './skin/CapeButton.vue'
export { default as CapeLikeTextButton } from './skin/CapeLikeTextButton.vue'
export { default as SkinButton } from './skin/SkinButton.vue'
export { default as SkinLikeTextButton } from './skin/SkinLikeTextButton.vue'
export { default as SkinPreviewRenderer } from './skin/SkinPreviewRenderer.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
export { default as VersionFilterControl } from './version/VersionFilterControl.vue'
export { default as VersionSummary } from './version/VersionSummary.vue'
// Settings
export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as ServersSpecs } from './billing/ServersSpecs.vue'
export { default as BackupCreateModal } from './servers/backups/BackupCreateModal.vue'
export { default as BackupDeleteModal } from './servers/backups/BackupDeleteModal.vue'
export { default as BackupItem } from './servers/backups/BackupItem.vue'
export { default as BackupRenameModal } from './servers/backups/BackupRenameModal.vue'
export { default as BackupRestoreModal } from './servers/backups/BackupRestoreModal.vue'
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
export { default as LoaderIcon } from './servers/icons/LoaderIcon.vue'
export { default as ServerIcon } from './servers/icons/ServerIcon.vue'
export { default as ServerInfoLabels } from './servers/labels/ServerInfoLabels.vue'
export { default as MedalBackgroundImage } from './servers/marketing/MedalBackgroundImage.vue'
export { default as MedalServerListing } from './servers/marketing/MedalServerListing.vue'
export type { PendingChange } from './servers/ServerListing.vue'
export { default as ServerListing } from './servers/ServerListing.vue'
export { default as ServersPromo } from './servers/ServersPromo.vue'
export * from './search'
export * from './servers'
export * from './settings'
export * from './skin'
export * from './version'

View File

@@ -0,0 +1,6 @@
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export { default as ShareModal } from './ShareModal.vue'
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
export { default as TabbedModal } from './TabbedModal.vue'

View File

@@ -1,50 +0,0 @@
<script setup>
import Button from '../base/Button.vue'
defineProps({
link: {
type: String,
default: null,
},
external: {
type: Boolean,
default: false,
},
action: {
type: Function,
default: null,
},
selected: {
type: Boolean,
default: false,
},
label: {
type: String,
required: true,
},
icon: {
type: String,
default: null,
},
})
</script>
<template>
<Button
:link="link"
:external="external"
:action="action"
design="nav"
class="quiet-disabled"
:class="{
selected: selected,
}"
:disabled="selected"
:navlabel="label"
>
<slot />
{{ label }}
</Button>
</template>
<style lang="scss" scoped></style>

View File

@@ -1,166 +0,0 @@
<template>
<nav class="navigation">
<router-link
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="linkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
<span>{{ link.label }}</span>
</router-link>
<div
class="nav-indicator"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
/>
</nav>
</template>
<script>
export default {
props: {
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
},
data() {
return {
sliderPositionX: 0,
sliderPositionY: 18,
selectedElementWidth: 0,
activeIndex: -1,
oldIndex: -1,
}
},
computed: {
filteredLinks() {
return this.links.filter((x) => (x.shown === undefined ? true : x.shown))
},
positionToMoveX() {
return `${this.sliderPositionX}px`
},
positionToMoveY() {
return `${this.sliderPositionY}px`
},
sliderWidth() {
return `${this.selectedElementWidth}px`
},
},
watch: {
'$route.path': {
handler() {
this.pickLink()
},
},
'$route.query': {
handler() {
if (this.query) this.pickLink()
},
},
},
mounted() {
window.addEventListener('resize', this.pickLink)
this.pickLink()
},
unmounted() {
window.removeEventListener('resize', this.pickLink)
},
methods: {
pickLink() {
this.activeIndex = this.query
? this.filteredLinks.findIndex(
(x) => (x.href === '' ? undefined : x.href) === this.$route.path[this.query],
)
: this.filteredLinks.findIndex((x) => x.href === decodeURIComponent(this.$route.path))
if (this.activeIndex !== -1) {
this.startAnimation()
} else {
this.oldIndex = -1
this.sliderPositionX = 0
this.selectedElementWidth = 0
}
},
startAnimation() {
const el = this.$refs.linkElements[this.activeIndex].$el
this.sliderPositionX = el.offsetLeft
this.sliderPositionY = el.offsetTop + el.offsetHeight
this.selectedElementWidth = el.offsetWidth
},
},
}
</script>
<style lang="scss" scoped>
.navigation {
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 1rem;
flex-wrap: wrap;
position: relative;
.nav-link {
text-transform: capitalize;
font-weight: var(--font-weight-bold);
color: var(--color-base);
position: relative;
&:hover {
color: var(--color-base);
&::after {
opacity: 0.4;
}
}
&:active::after {
opacity: 0.2;
}
&.router-link-exact-active {
color: var(--color-base);
&::after {
opacity: 1;
}
}
}
&.use-animation {
.nav-link {
&.is-active::after {
opacity: 0;
}
}
}
.nav-indicator {
position: absolute;
height: 0.25rem;
bottom: -5px;
left: 0;
width: 3rem;
transition: all ease-in-out 0.2s;
border-radius: var(--radius-max);
background-color: var(--color-brand);
@media (prefers-reduced-motion) {
transition: none !important;
}
}
}
</style>

View File

@@ -1,22 +0,0 @@
<template>
<div class="omorphia__navstack">
<slot />
</div>
</template>
<style lang="scss" scoped>
.omorphia__navstack {
display: flex;
flex-direction: column;
:deep(.btn) {
position: relative;
width: 100%;
&.selected {
background-color: var(--color-button-bg);
font-weight: bold;
}
}
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as Breadcrumbs } from './Breadcrumbs.vue'
export { default as NotificationPanel } from './NotificationPanel.vue'
export { default as PagewideBanner } from './PagewideBanner.vue'

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { injectPageContext } from '@modrinth/ui'
defineProps<{
sidebar?: 'right' | 'left'
}>()
const { hierarchicalSidebarAvailable } = injectPageContext()
</script>
<template>
<div
class="ui-normal-page"
:class="{
'ui-normal-page--sidebar-left': sidebar === 'left' && !hierarchicalSidebarAvailable,
'ui-normal-page--sidebar-right': sidebar === 'right' && !hierarchicalSidebarAvailable,
}"
>
<div class="ui-normal-page__header">
<slot name="header" />
</div>
<div class="ui-normal-page__content">
<slot />
</div>
<template v-if="sidebar">
<template v-if="hierarchicalSidebarAvailable">
<Teleport to="#sidebar-teleport-target">
<slot name="sidebar" />
</Teleport>
</template>
<template v-else>
<div class="ui-normal-page__sidebar">
<slot name="sidebar" />
</div>
</template>
</template>
</div>
</template>
<style scoped>
.ui-normal-page {
@apply grid gap-6 mx-auto py-4;
width: min(calc(100% - 2rem), calc(80rem - 3rem));
grid-template:
'header'
'content'
'sidebar'
/ 100%;
}
@media (width >= 64rem) {
.ui-normal-page--sidebar-left {
grid-template:
'header header'
'sidebar content'
'sidebar dummy'
/ 20rem 1fr;
}
.ui-normal-page--sidebar-right {
grid-template:
'header header'
'content sidebar'
'dummy sidebar'
/ 1fr 20rem;
}
}
.ui-normal-page__header {
grid-area: header;
}
.ui-normal-page__content {
grid-area: content;
}
.ui-normal-page__sidebar {
grid-area: sidebar;
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { injectPageContext } from '../../providers'
const { hierarchicalSidebarAvailable } = injectPageContext()
defineProps<{
title: string
}>()
</script>
<template>
<div
class="flex flex-col gap-3 p-4"
:class="{
'card-shadow mb-4 last:mb-0 rounded-2xl bg-bg-raised': !hierarchicalSidebarAvailable,
}"
>
<span class="font-semibold">{{ title }}</span>
<slot />
</div>
</template>

View File

@@ -0,0 +1,2 @@
export { default as NormalPage } from './NormalPage.vue'
export { default as SidebarCard } from './SidebarCard.vue'

View File

@@ -1,107 +0,0 @@
<template>
<div>
<Accordion
v-for="filter in filters"
:key="filter.id"
v-model="filters"
v-bind="$attrs"
:button-class="buttonClass"
:content-class="contentClass"
open-by-default
>
<template #title>
<slot name="header" :filter="filter">
<h2>{{ filter.formatted_name }}</h2>
</slot>
</template>
<template #default>
<template v-for="option in filter.options" :key="`${filter.id}-${option}`">
<slot name="option" :filter="filter" :option="option">
<div>
{{ option.formatted_name }}
</div>
</slot>
</template>
</template>
</Accordion>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Accordion from '../base/Accordion.vue'
interface FilterOption<T> {
id: string
formatted_name: string
data: T
}
interface FilterType<T> {
id: string
formatted_name: string
scrollable?: boolean
options: FilterOption<T>[]
}
interface GameVersion {
version: string
version_type: 'release' | 'snapshot' | 'alpha' | 'beta'
date: string
major: boolean
}
type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
interface Platform {
name: string
icon: string
supported_project_types: ProjectType[]
default: boolean
formatted_name: string
}
const props = defineProps<{
buttonClass?: string
contentClass?: string
gameVersions?: GameVersion[]
platforms: Platform[]
}>()
const filters = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const filters: FilterType<any>[] = [
{
id: 'platform',
formatted_name: 'Platform',
options:
props.platforms
.filter((x) => x.default && x.supported_project_types.includes('modpack'))
.map((x) => ({
id: x.name,
formatted_name: x.formatted_name,
data: x,
})) || [],
},
{
id: 'gameVersion',
formatted_name: 'Game version',
options:
props.gameVersions
?.filter((x) => x.major && x.version_type === 'release')
.map((x) => ({
id: x.version,
formatted_name: x.version,
data: x,
})) || [],
},
]
return filters
})
defineOptions({
inheritAttrs: false,
})
</script>

View File

@@ -1,346 +0,0 @@
<template>
<div
ref="dropdown"
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="animated-dropdown"
@keydown.up.prevent="focusPreviousOption"
@keydown.down.prevent="focusNextOptionOrOpen"
>
<div class="iconified-input">
<SearchIcon />
<input
:value="modelValue"
type="text"
:name="name"
:disabled="disabled"
class="text-input"
autocomplete="off"
autocapitalize="off"
:placeholder="placeholder"
:class="{ down: !renderUp, up: renderUp }"
@input="$emit('update:modelValue', $event.target.value)"
@focus="onFocus"
@blur="onBlur"
@focusout="onBlur"
@keydown.enter.prevent="$emit('enter')"
/>
<Button :disabled="disabled" class="r-btn" @click="() => $emit('update:modelValue', '')">
<XIcon />
</Button>
</div>
<div ref="dropdownOptions" class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
<transition name="options">
<div
v-show="dropdownVisible"
class="options"
role="listbox"
:class="{ down: !renderUp, up: renderUp }"
>
<div
v-for="(option, index) in options"
:key="index"
ref="optionElements"
tabindex="-1"
role="option"
class="option"
@click="selectOption(option)"
>
<div class="project-label">
<Avatar :src="option.icon" :circle="circledIcons" />
<div class="text">
<div class="title">
{{ getOptionLabel(option.title) }}
</div>
<div class="author">
{{ getOptionLabel(option.subtitle) }}
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { SearchIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Avatar from '../base/Avatar.vue'
import Button from '../base/Button.vue'
const props = defineProps({
options: {
type: Array,
required: true,
},
name: {
type: String,
required: true,
},
placeholder: {
type: [String, Number],
default: null,
},
modelValue: {
type: [String, Number, Object],
default: null,
},
renderUp: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
displayName: {
type: Function,
default: undefined,
},
circledIcons: {
type: Boolean,
default: false,
},
})
function getOptionLabel(option) {
return props.displayName?.(option) ?? option
}
const emit = defineEmits(['input', 'onSelected', 'update:modelValue', 'enter'])
const dropdownVisible = ref(false)
const focusedOptionIndex = ref(null)
const dropdown = ref(null)
const optionElements = ref(null)
const dropdownOptions = ref(null)
const toggleDropdown = () => {
if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value
dropdown.value.focus()
}
}
const selectOption = (option) => {
emit('onSelected', option)
console.log('onSelected', option)
dropdownVisible.value = false
}
const onFocus = () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex(
(option) => option === props.modelValue.value,
)
dropdownVisible.value = true
}
}
const onBlur = (event) => {
console.log(event)
if (!isChildOfDropdown(event.relatedTarget)) {
dropdownVisible.value = false
}
}
const focusPreviousOption = () => {
if (!props.disabled) {
if (!dropdownVisible.value) {
toggleDropdown()
}
focusedOptionIndex.value =
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
optionElements.value[focusedOptionIndex.value].focus()
}
}
const focusNextOptionOrOpen = () => {
if (!props.disabled) {
if (!dropdownVisible.value) {
toggleDropdown()
}
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
optionElements.value[focusedOptionIndex.value].focus()
}
}
const isChildOfDropdown = (element) => {
let currentNode = element
while (currentNode) {
if (currentNode === dropdownOptions.value) {
return true
}
currentNode = currentNode.parentNode
}
return false
}
</script>
<style lang="scss" scoped>
.animated-dropdown {
width: 20rem;
height: 2.5rem;
position: relative;
display: inline-block;
&:focus {
outline: 0;
}
.selected {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--gap-sm) var(--gap-lg);
background-color: var(--color-button-bg);
gap: var(--gap-md);
cursor: pointer;
user-select: none;
border-radius: var(--radius-md);
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
&.disabled {
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
&.render-up {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
&.render-down {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
&:focus {
outline: 0;
filter: brightness(1.25);
transition: filter 0.1s ease-in-out;
}
}
.options {
z-index: 10;
max-height: 18rem;
overflow-y: auto;
.option {
background-color: var(--color-button-bg);
display: flex;
align-items: center;
padding: var(--gap-md);
cursor: pointer;
user-select: none;
&:hover {
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;
}
&:focus {
outline: 0;
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;
}
&.selected-option {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bolder;
}
input {
display: none;
}
}
}
}
.options-enter-active,
.options-leave-active {
transition: transform 0.2s ease;
}
.options-enter-from,
.options-leave-to {
// this is not 100% due to a safari bug
&.up {
transform: translateY(99.999%);
}
&.down {
transform: translateY(-99.999%);
}
}
.options-enter-to,
.options-leave-from {
&.up {
transform: translateY(0%);
}
}
.options-wrapper {
position: absolute;
width: 100%;
overflow: auto;
z-index: 9;
&.up {
top: 0;
transform: translateY(-99.999%);
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
&.down {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
}
.project-label {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--gap-md);
color: var(--color-contrast);
.title {
font-weight: bold;
}
}
.iconified-input {
width: 100%;
}
.text-input {
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent !important;
width: 100%;
transition: 0.05s;
&:focus {
&.down {
border-radius: var(--radius-md) var(--radius-md) 0 0 !important;
}
&.up {
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
}
}
&:not(:focus) {
transition-delay: 0.2s;
}
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<Checkbox
class="filter"
:model-value="isActive"
:description="displayName"
@update:model-value="toggle"
>
<div class="filter-text">
<div v-if="props.icon" aria-hidden="true" class="icon" v-html="props.icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ props.displayName }}</span>
</div>
</Checkbox>
</template>
<script setup>
import { computed } from 'vue'
import Checkbox from '../base/Checkbox.vue'
const props = defineProps({
facetName: {
type: String,
default: '',
},
displayName: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
activeFilters: {
type: Array,
default() {
return []
},
},
})
const isActive = computed(() => props.activeFilters.includes(props.facetName))
const emit = defineEmits(['toggle'])
const toggle = () => {
emit('toggle', props.facetName)
}
</script>
<style lang="scss" scoped>
.filter {
margin-bottom: 0.5rem;
:deep(.filter-text) {
display: flex;
align-items: center;
.icon {
height: 1rem;
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
}
</style>

View File

@@ -0,0 +1,4 @@
export { default as Categories } from './Categories.vue'
export { default as SearchFilterControl } from './SearchFilterControl.vue'
export { default as SearchFilterOption } from './SearchFilterOption.vue'
export { default as SearchSidebarFilter } from './SearchSidebarFilter.vue'

View File

@@ -0,0 +1,2 @@
export { default as LoaderIcon } from './LoaderIcon.vue'
export { default as ServerIcon } from './ServerIcon.vue'

View File

@@ -0,0 +1,7 @@
export * from './backups'
export * from './icons'
export * from './labels'
export * from './marketing'
export type { PendingChange } from './ServerListing.vue'
export { default as ServerListing } from './ServerListing.vue'
export { default as ServersPromo } from './ServersPromo.vue'

View File

@@ -0,0 +1 @@
export { default as ServerInfoLabels } from './ServerInfoLabels.vue'

View File

@@ -0,0 +1,2 @@
export { default as MedalBackgroundImage } from './MedalBackgroundImage.vue'
export { default as MedalServerListing } from './MedalServerListing.vue'

View File

@@ -0,0 +1 @@
export { default as ThemeSelector } from './ThemeSelector.vue'

View File

@@ -0,0 +1,5 @@
export { default as CapeButton } from './CapeButton.vue'
export { default as CapeLikeTextButton } from './CapeLikeTextButton.vue'
export { default as SkinButton } from './SkinButton.vue'
export { default as SkinLikeTextButton } from './SkinLikeTextButton.vue'
export { default as SkinPreviewRenderer } from './SkinPreviewRenderer.vue'

View File

@@ -0,0 +1,3 @@
export { default as VersionChannelIndicator } from './VersionChannelIndicator.vue'
export { default as VersionFilterControl } from './VersionFilterControl.vue'
export { default as VersionSummary } from './VersionSummary.vue'

View File

@@ -551,6 +551,12 @@
"project-type.plugin.lowercase": {
"defaultMessage": "{count, plural, one {plugin} other {plugins}}"
},
"project-type.project.category": {
"defaultMessage": "Projects"
},
"project-type.project.lowercase": {
"defaultMessage": "{count, plural, one {project} other {projects}}"
},
"project-type.resourcepack.capital": {
"defaultMessage": "{count, plural, one {Resource Pack} other {Resource Packs}}"
},

View File

@@ -79,6 +79,7 @@ export function createContext<ContextValue>(
}
export * from './api-client'
export * from './page-context'
export * from './project-page'
export * from './server-context'
export * from './web-notifications'

View File

@@ -0,0 +1,14 @@
import type { Ref } from 'vue'
import { createContext } from '.'
export interface PageContext {
// pages may render sidebar content in #sidebar-teleport-target instead of in the main layout when true
hierarchicalSidebarAvailable: Ref<boolean>
showAds: Ref<boolean>
}
export const [injectPageContext, providePageContext] = createContext<PageContext>(
'root',
'pageContext',
)

View File

@@ -0,0 +1,13 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import { createContext } from '.'
export interface UserPageContext {
user: Ref<Labrinth.Users.v3.User>
}
export const [injectUserPageContext, provideUserPageContext] = createContext<UserPageContext>(
'root',
'userPageContext',
)

View File

@@ -0,0 +1,14 @@
@layer utilities {
.heading-xl {
@apply m-0 text-xl font-semibold leading-none text-contrast;
}
.heading-2xl {
@apply m-0 text-2xl font-semibold leading-none text-contrast;
}
.heading-3xl {
@apply m-0 text-3xl font-semibold leading-none text-contrast;
}
.heading-4xl {
@apply m-0 text-4xl font-semibold leading-none text-contrast;
}
}

View File

@@ -437,6 +437,10 @@ export const commonProjectTypeCategoryMessages = defineMessages({
id: 'project-type.server.category',
defaultMessage: 'Servers',
},
project: {
id: 'project-type.project.category',
defaultMessage: 'Projects',
},
})
export const commonProjectTypeTitleMessages = defineMessages({
@@ -468,6 +472,10 @@ export const commonProjectTypeTitleMessages = defineMessages({
id: 'project-type.server.capital',
defaultMessage: '{count, plural, one {Server} other {Servers}}',
},
project: {
id: 'project-type.project.lowercase',
defaultMessage: '{count, plural, one {Project} other {Projects}}',
},
})
export const commonProjectTypeSentenceMessages = defineMessages({
@@ -499,6 +507,10 @@ export const commonProjectTypeSentenceMessages = defineMessages({
id: 'project-type.server.lowercase',
defaultMessage: '{count, plural, one {server} other {servers}}',
},
project: {
id: 'project-type.project.lowercase',
defaultMessage: '{count, plural, one {project} other {projects}}',
},
})
export const commonSettingsMessages = defineMessages({

View File

@@ -3,3 +3,4 @@ export * from './game-modes'
export * from './notices'
export * from './savable'
export * from './search'
export * from './vue-children'

View File

@@ -8,7 +8,7 @@ import { createTextVNode, isVNode, toDisplayString, type VNode } from 'vue'
* @returns Either the original VNode or a text VNode containing child converted
* to a display string.
*/
function normalizeChild(child: any): VNode {
function normalizeChild(child: unknown): VNode {
return isVNode(child) ? child : createTextVNode(toDisplayString(child))
}
@@ -20,6 +20,6 @@ function normalizeChild(child: any): VNode {
* @param children Children to normalize.
* @returns Children with all of non-VNodes converted to display strings.
*/
export function normalizeChildren(children: any | any[]): VNode[] {
export function normalizeChildren(children: unknown | unknown[]): VNode[] {
return Array.isArray(children) ? children.map(normalizeChild) : [normalizeChild(children)]
}