You've already forked AstralRinth
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -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>
|
||||
@@ -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<{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
128
packages/api-client/src/modules/labrinth/collections.ts
Normal file
128
packages/api-client/src/modules/labrinth/collections.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './billing/internal'
|
||||
export * from './collections'
|
||||
export * from './projects/v2'
|
||||
export * from './projects/v3'
|
||||
export * from './state'
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
|
||||
1
packages/assets/icons/heart-minus.svg
Normal file
1
packages/assets/icons/heart-minus.svg
Normal 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 |
47
packages/assets/illustrations/empty.svg
Normal file
47
packages/assets/illustrations/empty.svg
Normal 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 |
@@ -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
|
||||
|
||||
2
packages/ui/src/components/affiliate/index.ts
Normal file
2
packages/ui/src/components/affiliate/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AffiliateLinkCard } from './AffiliateLinkCard.vue'
|
||||
export { default as AffiliateLinkCreateModal } from './AffiliateLinkCreateModal.vue'
|
||||
3
packages/ui/src/components/base/HorizontalRule.vue
Normal file
3
packages/ui/src/components/base/HorizontalRule.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="h-[1px] w-full bg-divider"></div>
|
||||
</template>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
56
packages/ui/src/components/base/index.ts
Normal file
56
packages/ui/src/components/base/index.ts
Normal 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'
|
||||
5
packages/ui/src/components/billing/index.ts
Normal file
5
packages/ui/src/components/billing/index.ts
Normal 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'
|
||||
2
packages/ui/src/components/brand/index.ts
Normal file
2
packages/ui/src/components/brand/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AnimatedLogo } from './AnimatedLogo.vue'
|
||||
export { default as TextLogo } from './TextLogo.vue'
|
||||
1
packages/ui/src/components/changelog/index.ts
Normal file
1
packages/ui/src/components/changelog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChangelogEntry } from './ChangelogEntry.vue'
|
||||
2
packages/ui/src/components/chart/index.ts
Normal file
2
packages/ui/src/components/chart/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Chart } from './Chart.vue'
|
||||
export { default as CompactChart } from './CompactChart.vue'
|
||||
3
packages/ui/src/components/content/index.ts
Normal file
3
packages/ui/src/components/content/index.ts
Normal 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'
|
||||
@@ -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'
|
||||
|
||||
6
packages/ui/src/components/modal/index.ts
Normal file
6
packages/ui/src/components/modal/index.ts
Normal 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'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
3
packages/ui/src/components/nav/index.ts
Normal file
3
packages/ui/src/components/nav/index.ts
Normal 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'
|
||||
78
packages/ui/src/components/page/NormalPage.vue
Normal file
78
packages/ui/src/components/page/NormalPage.vue
Normal 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>
|
||||
20
packages/ui/src/components/page/SidebarCard.vue
Normal file
20
packages/ui/src/components/page/SidebarCard.vue
Normal 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>
|
||||
2
packages/ui/src/components/page/index.ts
Normal file
2
packages/ui/src/components/page/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as NormalPage } from './NormalPage.vue'
|
||||
export { default as SidebarCard } from './SidebarCard.vue'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
4
packages/ui/src/components/search/index.ts
Normal file
4
packages/ui/src/components/search/index.ts
Normal 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'
|
||||
2
packages/ui/src/components/servers/icons/index.ts
Normal file
2
packages/ui/src/components/servers/icons/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoaderIcon } from './LoaderIcon.vue'
|
||||
export { default as ServerIcon } from './ServerIcon.vue'
|
||||
7
packages/ui/src/components/servers/index.ts
Normal file
7
packages/ui/src/components/servers/index.ts
Normal 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'
|
||||
1
packages/ui/src/components/servers/labels/index.ts
Normal file
1
packages/ui/src/components/servers/labels/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ServerInfoLabels } from './ServerInfoLabels.vue'
|
||||
2
packages/ui/src/components/servers/marketing/index.ts
Normal file
2
packages/ui/src/components/servers/marketing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as MedalBackgroundImage } from './MedalBackgroundImage.vue'
|
||||
export { default as MedalServerListing } from './MedalServerListing.vue'
|
||||
1
packages/ui/src/components/settings/index.ts
Normal file
1
packages/ui/src/components/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ThemeSelector } from './ThemeSelector.vue'
|
||||
5
packages/ui/src/components/skin/index.ts
Normal file
5
packages/ui/src/components/skin/index.ts
Normal 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'
|
||||
3
packages/ui/src/components/version/index.ts
Normal file
3
packages/ui/src/components/version/index.ts
Normal 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'
|
||||
@@ -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}}"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
14
packages/ui/src/providers/page-context.ts
Normal file
14
packages/ui/src/providers/page-context.ts
Normal 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',
|
||||
)
|
||||
13
packages/ui/src/providers/user-page.ts
Normal file
13
packages/ui/src/providers/user-page.ts
Normal 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',
|
||||
)
|
||||
14
packages/ui/src/styles/tailwind-utilities.css
Normal file
14
packages/ui/src/styles/tailwind-utilities.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './game-modes'
|
||||
export * from './notices'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
export * from './vue-children'
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
Reference in New Issue
Block a user